Repository navigation
Merge pull request #216 from fulll/fix/docs-snapshot-merge-pathspec-glob #27
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |