Skip to content

Merge pull request #216 from fulll/fix/docs-snapshot-merge-pathspec-glob #27

Merge pull request #216 from fulll/fix/docs-snapshot-merge-pathspec-glob

Merge pull request #216 from fulll/fix/docs-snapshot-merge-pathspec-glob #27

Workflow file for this run

name: Docs
# Triggers:
# - push to main touching docs/** or this workflow → deploy latest to GitHub Pages
# - push of a major release tag (v2.0.0, v3.0.0 …) → versioned snapshot
# - workflow_dispatch → manual deploy of latest
on:
push:
branches: [main]
paths:
- "docs/**"
- ".github/workflows/docs.yaml"
tags:
# Glob pattern (not regex): matches v1.0.0, v2.0.0, v10.0.0 …
- "v[0-9]*.0.0"
workflow_dispatch:
# Deployment strategy: GitHub Actions Pages source (actions/deploy-pages — official, no third party).
# • gh-pages branch = versioned snapshot STORAGE only (not served directly by Pages)
# • Every deploy job assembles a combined artifact:
# - latest docs built from main at the artifact root
# - each vX/ snapshot from the gh-pages branch merged in
# → single artifact deployed via actions/deploy-pages
# • Snapshot job stores the built snapshot in gh-pages branch via plain git,
# then merges a PR updating versions.json on main. That merge is itself
# GITHUB_TOKEN-authored (via the API), so it does NOT cascade into a new
# `push`-triggered run — the snapshot job explicitly dispatches this
# workflow (workflow_dispatch) for main right after merging, so the new
# snapshot goes live immediately instead of waiting for an unrelated
# docs change.
# Requires: Settings → Pages → Source: GitHub Actions.
permissions:
contents: write
pages: write
id-token: write
# Only one concurrent deployment for the same ref; cancel outdated runs.
concurrency:
group: docs-${{ github.ref }}
cancel-in-progress: true
jobs:
# ── Deploy (latest) ─────────────────────────────────────────────────────────
# Assembles a combined Pages artifact:
# 1. Builds the latest docs (base: /github-code-search/)
# 2. Merges existing versioned snapshots from the gh-pages storage branch
# into the artifact (docs/.vitepress/dist/vX/ for each stored version)
# 3. Uploads the artifact and deploys via actions/deploy-pages
# Requires: Settings → Pages → Source: GitHub Actions.
deploy:
name: Build and deploy docs
if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Needed to fetch the gh-pages storage branch for versioned snapshots.
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build latest docs
run: bun run docs:build
# Base URL: /github-code-search/ (default in config.mts)
- name: Merge versioned snapshots from gh-pages storage
run: |
set -euo pipefail
if git ls-remote --heads origin gh-pages | grep -q gh-pages; then
git fetch origin gh-pages
# List all top-level entries and filter with a strict regex —
# `ls-tree` pathspecs are matched literally (no glob expansion,
# unlike `git log`/`git diff`), so `-- 'v[0-9]*'` would silently
# match nothing and skip every snapshot.
for entry in $(git ls-tree --name-only origin/gh-pages); do
if echo "$entry" | grep -qE '^v[0-9]+$'; then
echo "Merging snapshot: $entry"
mkdir -p "docs/.vitepress/dist/$entry"
git archive origin/gh-pages "$entry" | tar -x -C docs/.vitepress/dist/
# Validate: warn and remove if the extracted directory is empty.
if [ -z "$(ls -A "docs/.vitepress/dist/$entry" 2>/dev/null)" ]; then
echo "Warning: snapshot '$entry' is empty after extraction — removing."
rmdir "docs/.vitepress/dist/$entry"
fi
fi
done
else
echo "No gh-pages branch yet — skipping snapshot merge"
fi
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
with:
path: docs/.vitepress/dist
- name: Deploy to GitHub Pages
id: deploy
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
# ── Snapshot (versioned) ────────────────────────────────────────────────────
# Triggered by a major release tag (e.g. v2.0.0).
# 1. Builds docs with a versioned base URL (/github-code-search/v2/).
# 2. Stores the output in the gh-pages branch under /v2/ using plain git
# (no third-party action). The gh-pages branch is storage only — Pages
# still points to GitHub Actions; the deploy job merges snapshots in.
# 3. Opens a PR with the versions.json + blog stub changes against main —
# main requires signed commits and passing status checks, so a direct
# push is rejected. Since GITHUB_TOKEN-authored events don't trigger new
# workflow runs, ci.yaml is explicitly dispatched (workflow_dispatch is
# exempt from that restriction) for this branch/SHA and awaited before
# merging. The merge itself is also GITHUB_TOKEN-authored, so it won't
# trigger this workflow's own `push` trigger either — this workflow is
# explicitly dispatched for main right after merging instead.
#
# Convention: only tags matching vX.0.0 (major bumps) trigger a snapshot.
# Patch and minor releases update the main docs in-place via the deploy job.
snapshot:
name: Snapshot versioned docs
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
actions: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Full history needed to push to gh-pages and commit versions.json to main.
fetch-depth: 0
- name: Extract major version from tag
id: ver
run: |
# Validate that the tag strictly matches vX.0.0 before proceeding.
# The workflow trigger filter (v[0-9]*.0.0) is the primary guard, but
# this ensures the script fails fast if triggered with an unexpected ref.
if ! echo "$GITHUB_REF_NAME" | grep -Eq '^v[0-9]+\.0\.0$'; then
echo "Error: '$GITHUB_REF_NAME' does not match expected pattern vX.0.0" >&2
exit 1
fi
MAJOR="${GITHUB_REF_NAME%%.*}" # e.g. v2 from v2.0.0
echo "major=$MAJOR" >> "$GITHUB_OUTPUT"
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build versioned snapshot
env:
# config.mts reads VITEPRESS_BASE when set; falls back to /github-code-search/
VITEPRESS_BASE: /github-code-search/${{ steps.ver.outputs.major }}/
run: bun run docs:build
- name: Store snapshot in gh-pages branch
run: |
set -euo pipefail
MAJOR="${{ steps.ver.outputs.major }}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# Set up the gh-pages worktree (create orphan branch if it doesn't exist yet).
if git ls-remote --heads origin gh-pages | grep -q gh-pages; then
git fetch origin gh-pages
git worktree add /tmp/gh-pages-storage origin/gh-pages
else
git worktree add --orphan -b gh-pages /tmp/gh-pages-storage
touch /tmp/gh-pages-storage/.nojekyll # prevent Jekyll processing
fi
# Copy the built snapshot into its versioned directory.
rm -rf "/tmp/gh-pages-storage/$MAJOR"
cp -r docs/.vitepress/dist "/tmp/gh-pages-storage/$MAJOR"
# Commit and push.
cd /tmp/gh-pages-storage
git add .
git diff --staged --quiet || git commit -m "docs: store snapshot $MAJOR [skip ci]"
git push origin gh-pages
# Explicit cleanup — belt-and-suspenders even on ephemeral runners.
git worktree remove /tmp/gh-pages-storage
- name: Prepend new version entry to versions.json
run: |
MAJOR="${{ steps.ver.outputs.major }}"
LINK="/${MAJOR}/"
# Idempotent — skip if the entry already exists. Otherwise, prepend
# the new version tagged "(latest)" and strip that suffix from any
# previous entry, since it's now superseded.
jq --arg text "$MAJOR (latest)" --arg link "$LINK" \
'if any(.[]; .link == $link) then .
else [{"text": $text, "link": $link}] + (map(.text |= sub(" [(]latest[)]$"; "")))
end' \
docs/public/versions.json > /tmp/versions_new.json
mv /tmp/versions_new.json docs/public/versions.json
- name: Generate blog post stub for new major version
run: |
set -euo pipefail
# Convert vX.0.0 → v<maj>-0-0 for the file name (e.g. v3.0.0 → v3-0-0)
TAG="$GITHUB_REF_NAME"
SLUG="${TAG//./-}" # v3.0.0 → v3-0-0
BLOG_FILE="docs/blog/release-${SLUG}.md"
RELEASE_DATE="$(date -u +%Y-%m-%d)"
# Idempotent — skip creation if the file already exists (manually authored).
if [ -f "$BLOG_FILE" ]; then
echo "Blog post $BLOG_FILE already exists — skipping stub generation."
else
# Fix: use Python to write the file so heredoc indentation never
# leaks into the generated Markdown (which would break frontmatter).
python3 - <<PY
import pathlib
tag = "$TAG"
release_date = "$RELEASE_DATE"
blog_file = "$BLOG_FILE"
content = (
f'---\ntitle: "What\'s new in {tag}"\n'
f'description: "Highlights of github-code-search {tag}"\n'
f'date: {release_date}\n---\n\n'
f'# What\'s new in github-code-search {tag}\n\n'
f'> Full release notes: <https://github.com/fulll/github-code-search/releases/tag/{tag}>\n\n'
'<!-- TODO: fill in feature highlights, usage examples and screenshots. -->\n'
)
pathlib.Path(blog_file).write_text(content, encoding="utf-8")
print(f"Created blog stub: {blog_file}")
PY
fi
- name: Update blog/index.md table with new major version
run: |
set -euo pipefail
TAG="$GITHUB_REF_NAME"
SLUG="${TAG//./-}"
# Add a row to the blog index table only if the version isn't already listed.
if grep -qF "release-${SLUG}" docs/blog/index.md; then
echo "Blog index already contains ${TAG} — skipping."
else
python3 - <<PY
import re, pathlib
path = pathlib.Path("docs/blog/index.md")
content = path.read_text()
tag = "$TAG"
slug = "$SLUG"
new_row = f"| [{tag}](./release-{slug}) | <!-- TODO: add summary --> |"
# Insert the new row after the last markdown table row (line ending with |).
updated = re.sub(
r"(\|[^\n]+\|)(\s*\Z)",
lambda m: m.group(1) + "\n" + new_row + m.group(2),
content,
count=1,
flags=re.DOTALL,
)
path.write_text(updated)
print(f"Inserted row for {tag} into blog/index.md")
PY
fi
- name: Open PR with versions.json and blog stub, run CI, merge, and deploy
# main is protected by a ruleset requiring signed commits and passing
# status checks — a direct push (previously via
# stefanzweifel/git-auto-commit-action) is rejected. Open a PR instead:
# GitHub's own merge-via-API produces a Verified commit.
#
# `gh pr merge --auto` alone is NOT enough here: this PR/branch is
# created using the default GITHUB_TOKEN, and events authored by that
# token don't cascade into new workflow runs — ci.yaml's `pull_request`
# trigger never fires, the required checks stay "expected" forever,
# and auto-merge would wait indefinitely. workflow_dispatch is
# exempted from that restriction, so we explicitly dispatch ci.yaml
# for this branch/SHA, wait for it, then merge directly once it's
# green — see review on PR #215. The merge push is ALSO
# GITHUB_TOKEN-authored, so this workflow's own `push` trigger won't
# fire either — dispatch it explicitly for main afterwards.
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
MAJOR="${{ steps.ver.outputs.major }}"
BRANCH="docs/${MAJOR}-snapshot-metadata"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add docs/public/versions.json docs/blog/
if git diff --staged --quiet; then
echo "No changes to commit — skipping."
exit 0
fi
# Re-base the pending changes onto a fresh branch off origin/main —
# the tag checkout and main's tip are normally the same commit, but
# this is robust even if main advanced in between.
git stash
git fetch origin main
git checkout -B "$BRANCH" origin/main
git stash pop
git add docs/public/versions.json docs/blog/
# The pre-rebase guard above doesn't cover this state: origin/main
# may already carry this exact metadata (e.g. a retry after it was
# applied manually), in which case there's nothing left to commit.
if git diff --staged --quiet; then
echo "No changes remaining after rebasing onto origin/main — already up to date, skipping."
exit 0
fi
git commit -m "docs: add ${MAJOR} to versions.json and blog"
git push --force origin "$BRANCH"
SHA="$(git rev-parse HEAD)"
PR_URL=$(gh pr create --base main --head "$BRANCH" \
--title "docs: add ${MAJOR} to versions.json and blog" \
--body "Automated versioned-docs snapshot metadata for \`${GITHUB_REF_NAME}\`." \
2>&1) || PR_URL=$(gh pr view "$BRANCH" --json url -q .url)
echo "Opened $PR_URL"
gh workflow run ci.yaml --ref "$BRANCH"
RUN_ID=""
for _ in $(seq 1 10); do
# Filter by headSha, not just the newest run for this branch — a
# retry (or an earlier attempt reusing this branch name) can leave
# a stale, already-green run around whose SHA no longer matches
# what was just pushed. See review on PR #215.
# Note: gh's --jq takes a single expression string — it has no
# --arg support like the jq binary, so interpolate $SHA directly
# (safe: it's the output of `git rev-parse HEAD`, not user input).
RUN_ID=$(gh run list --workflow=ci.yaml --branch="$BRANCH" \
--event=workflow_dispatch --limit=10 --json databaseId,headSha \
--jq "[.[] | select(.headSha == \"${SHA}\")] | .[0].databaseId // empty")
[ -n "$RUN_ID" ] && break
sleep 3
done
if [ -z "$RUN_ID" ]; then
echo "::error::Could not find the dispatched CI run for $BRANCH at $SHA" >&2
exit 1
fi
gh run watch "$RUN_ID" --exit-status
gh pr merge "$BRANCH" --squash
# This merge is itself GITHUB_TOKEN-authored, so the resulting push
# to main won't trigger docs.yaml's `push` trigger either — dispatch
# it explicitly so the deploy job picks up the new snapshot now,
# instead of waiting for the next unrelated docs change to main.
gh workflow run docs.yaml --ref main