Skip to content

Commit 0c4c089

Browse files
authored
workspace: centralize docs deploy as a reusable workflow, gates in make check (#87)
1 parent de92550 commit 0c4c089

9 files changed

Lines changed: 275 additions & 92 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
- name: Lint shell scripts with shellcheck
2525
uses: ludeeus/action-shellcheck@master
2626
with:
27-
scandir: './scripts ./plugins/asta-tools/hooks'
27+
scandir: './scripts ./plugins/asta-tools/hooks ./plugins/asta-tools/skills/workspace/assets'
2828

2929
- name: Check skill frontmatter against Agent Skills spec
3030
run: make check-skills
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
# Reusable workflow: build, deploy, and PR-preview a Quarto site on GitHub
2+
# Pages. Scaffolded research projects (see the asta-tools `workspace` skill)
3+
# call this from a thin `docs.yml` stub, so the deploy machinery is maintained
4+
# once here rather than copied into every project.
5+
#
6+
# Contract with the calling repo:
7+
# - A `make check` target runs the repo's quality gates and renders the site
8+
# to `_site/` (the workspace skill's Makefile asset provides the baseline;
9+
# repos add their own gates to that target, never as workflow steps).
10+
# - The caller grants `contents: write` + `pull-requests: write`; jobs here
11+
# downscope to least privilege individually.
12+
# - Full history and PR head refs are fetched before `make check`, for
13+
# repos whose checks derive content from git history.
14+
name: Quarto site
15+
16+
on:
17+
workflow_call:
18+
19+
jobs:
20+
# Build + quality gates (via `make check`). This is the required status
21+
# check. Concurrency is keyed per PR (or per branch for main), so a new
22+
# commit supersedes its own stale build while separate PRs and main never
23+
# cancel each other.
24+
build:
25+
if: github.event.action != 'closed'
26+
runs-on: ubuntu-latest
27+
permissions:
28+
contents: read
29+
concurrency:
30+
group: pages-${{ github.event.pull_request.number || github.ref }}
31+
cancel-in-progress: true
32+
steps:
33+
- uses: actions/checkout@v4
34+
with:
35+
fetch-depth: 0 # full history for checks that read git history
36+
37+
# PR head refs are permanent; fetch them so provenance commits referenced
38+
# by history-derived checks still resolve.
39+
- name: Fetch PR refs
40+
run: git fetch --no-tags origin '+refs/pull/*/head:refs/remotes/origin/pr/*'
41+
42+
- uses: quarto-dev/quarto-actions/setup@v2
43+
44+
# The shared render/validate logic is vendored into each project as
45+
# scripts/quarto-check.sh. Surface (never fail on) drift between that
46+
# copy and the canonical one shipped at this workflow's own commit, so
47+
# stale or hand-edited copies get noticed on every run.
48+
- name: Check vendored quarto-check.sh for drift
49+
run: |
50+
[ -f scripts/quarto-check.sh ] || exit 0
51+
canonical="https://raw.githubusercontent.com/allenai/asta-plugins/${{ github.job_workflow_sha }}/plugins/asta-tools/skills/workspace/assets/quarto-check.sh"
52+
if curl -fsSL "$canonical" -o /tmp/quarto-check.canonical; then
53+
if ! diff -u scripts/quarto-check.sh /tmp/quarto-check.canonical > /tmp/quarto-check.diff; then
54+
echo "::warning::scripts/quarto-check.sh differs from the workspace skill's canonical copy — re-copy assets/quarto-check.sh to update (or ignore if intentionally customized)"
55+
{ echo "## quarto-check.sh drift (vendored → canonical)"; echo '```diff'; cat /tmp/quarto-check.diff; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
56+
fi
57+
else
58+
echo "could not fetch canonical quarto-check.sh; skipping drift check"
59+
fi
60+
61+
# All quality gates live in the repo's `check` Makefile target — the
62+
# single source of truth shared with local runs, so CI and local can't
63+
# drift. Warnings are surfaced to the step summary from inside the target.
64+
- name: Run docs checks
65+
run: make check
66+
67+
- name: Prepare Pages artifact
68+
run: touch _site/.nojekyll # serve asset dirs verbatim (no Jekyll)
69+
70+
- name: Upload rendered site
71+
uses: actions/upload-artifact@v4
72+
with:
73+
name: site
74+
path: _site
75+
include-hidden-files: true # keep .nojekyll
76+
77+
# Publish: main -> gh-pages root (keeping PR previews); a PR -> a preview under
78+
# /pr-preview/pr-<N>/ with a link comment. Concurrency is keyed per PR (and
79+
# separately for main), so main and any PR are always in different groups — a
80+
# main deploy can't be queued behind or cancelled by a PR's write (including a
81+
# PR-close cleanup that fires at merge). Concurrent writes from different
82+
# groups reconcile via rebase-and-retry on push, not a global queue.
83+
deploy:
84+
needs: build
85+
if: github.event_name == 'push' || github.event.action != 'closed'
86+
runs-on: ubuntu-latest
87+
permissions:
88+
contents: write
89+
pull-requests: write
90+
concurrency:
91+
group: pages-${{ github.event.pull_request.number || github.ref }}
92+
cancel-in-progress: true
93+
steps:
94+
- uses: actions/checkout@v4
95+
- uses: actions/download-artifact@v4
96+
with:
97+
name: site
98+
path: _site
99+
100+
- name: Publish to gh-pages
101+
env:
102+
PR_NUMBER: ${{ github.event.pull_request.number }}
103+
GH_TOKEN: ${{ github.token }}
104+
run: |
105+
git config user.name "github-actions[bot]"
106+
git config user.email "github-actions[bot]@users.noreply.github.com"
107+
# First deploy: create gh-pages from the empty tree without touching
108+
# the main checkout (an orphan checkout here would make the worktree
109+
# add below fail — a branch can't be checked out twice).
110+
git fetch origin gh-pages:gh-pages 2>/dev/null \
111+
|| git branch gh-pages "$(git commit-tree "$(git hash-object -t tree /dev/null)" -m "Initialize gh-pages")"
112+
tmp=$(mktemp -d)
113+
git worktree add "$tmp" gh-pages
114+
115+
if [ "${{ github.event_name }}" = "push" ]; then
116+
# Main deploy: replace the site root, keep pr-preview/.
117+
find "$tmp" -mindepth 1 -maxdepth 1 ! -name pr-preview ! -name .git -exec rm -rf {} +
118+
cp -r "$GITHUB_WORKSPACE/_site/." "$tmp/"
119+
else
120+
# PR preview: publish under pr-preview/pr-<N>/.
121+
rm -rf "$tmp/pr-preview/pr-$PR_NUMBER"
122+
mkdir -p "$tmp/pr-preview/pr-$PR_NUMBER"
123+
cp -r "$GITHUB_WORKSPACE/_site/." "$tmp/pr-preview/pr-$PR_NUMBER/"
124+
# Keep previews out of search indexes. A project Pages site can't use
125+
# a path-scoped robots.txt (only the host-root one is honored), so
126+
# inject a noindex meta tag into the preview's pages (main is left
127+
# indexable).
128+
find "$tmp/pr-preview/pr-$PR_NUMBER" -name '*.html' -print0 \
129+
| xargs -0 sed -i 's#<head>#<head><meta name="robots" content="noindex, nofollow">#'
130+
fi
131+
132+
cd "$tmp"
133+
git add -A
134+
if git diff --cached --quiet; then
135+
echo "gh-pages already up to date"
136+
else
137+
git commit -m "Deploy ${{ github.event_name }} ${{ github.sha }}"
138+
for i in 1 2 3 4 5; do
139+
git push origin gh-pages && break
140+
[ "$i" = 5 ] && { echo "::error::could not push gh-pages after 5 attempts"; exit 1; }
141+
echo "gh-pages moved underneath us — rebasing and retrying ($i/5)"
142+
git pull --rebase origin gh-pages || { git rebase --abort 2>/dev/null || true; echo "::error::gh-pages rebase conflict"; exit 1; }
143+
done
144+
fi
145+
146+
# For a PR, ensure exactly one preview-link comment (create or update),
147+
# independent of whether the content changed. Best-effort — a comment
148+
# hiccup shouldn't fail an otherwise-successful publish.
149+
if [ "${{ github.event_name }}" = "pull_request" ]; then
150+
url="https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/pr-preview/pr-$PR_NUMBER/"
151+
body="📄 Preview: $url"
152+
existing=$(gh api "repos/${{ github.repository }}/issues/$PR_NUMBER/comments" \
153+
--jq 'map(select(.body | startswith("📄 Preview:"))) | .[0].id // empty')
154+
if [ -n "$existing" ]; then
155+
gh api "repos/${{ github.repository }}/issues/comments/$existing" -X PATCH -f body="$body" >/dev/null \
156+
|| echo "::warning::could not update preview comment"
157+
else
158+
gh api "repos/${{ github.repository }}/issues/$PR_NUMBER/comments" -f body="$body" >/dev/null \
159+
|| echo "::warning::could not create preview comment"
160+
fi
161+
fi
162+
163+
# Remove a PR's preview when the PR closes. Shares that PR's concurrency group
164+
# (keyed on the PR number) so it cancels any still-running build/deploy for the
165+
# PR first — otherwise a late deploy could recreate the preview after removal.
166+
cleanup-preview:
167+
if: github.event_name == 'pull_request' && github.event.action == 'closed'
168+
runs-on: ubuntu-latest
169+
permissions:
170+
contents: write
171+
concurrency:
172+
group: pages-${{ github.event.pull_request.number || github.ref }}
173+
cancel-in-progress: true
174+
steps:
175+
- uses: actions/checkout@v4
176+
- name: Remove PR preview
177+
env:
178+
PR_NUMBER: ${{ github.event.pull_request.number }}
179+
run: |
180+
git config user.name "github-actions[bot]"
181+
git config user.email "github-actions[bot]@users.noreply.github.com"
182+
git fetch origin gh-pages:gh-pages 2>/dev/null || exit 0
183+
tmp=$(mktemp -d)
184+
git worktree add "$tmp" gh-pages
185+
rm -rf "$tmp/pr-preview/pr-$PR_NUMBER"
186+
cd "$tmp"
187+
git add -A
188+
if git diff --cached --quiet; then echo "no preview to remove"; exit 0; fi
189+
git commit -m "Remove preview for PR #$PR_NUMBER"
190+
for i in 1 2 3 4 5; do
191+
git push origin gh-pages && break
192+
[ "$i" = 5 ] && { echo "::error::could not push gh-pages after 5 attempts"; exit 1; }
193+
echo "gh-pages moved underneath us — rebasing and retrying ($i/5)"
194+
git pull --rebase origin gh-pages || { git rebase --abort 2>/dev/null || true; echo "::error::gh-pages rebase conflict"; exit 1; }
195+
done

DEVELOPER.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ Design rules worth knowing before you change code:
1919
- New external-tool integrations go through the passthrough system
2020
(`asta.utils.passthrough` + `passthrough.conf`), not bespoke wrappers.
2121

22+
One directory is *not* this repo's own CI:
23+
`.github/workflows/workspace-quarto-site.yml` is a published reusable workflow
24+
(`workflow_call`-only) for the research projects the `workspace` skill
25+
scaffolds — GitHub mandates the `.github/workflows/` path for reusable
26+
workflows, so it can't live with the skill. Maintain it together with
27+
`plugins/asta-tools/skills/workspace/` (its `assets/docs.yml` stub and
28+
`assets/Makefile` `check` target form one contract with it).
29+
2230
## Development Setup
2331

2432
Prerequisites: Python 3.11+, `uv`, `make`, and Node.js 20+ / `npx`

plugins/asta-tools/skills/workspace/SKILL.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,14 @@ Before writing any file in the steps below, check whether the target path alread
4646
1. Copy `assets/_quarto.yml` to project root; fill `{{TITLE}}`.
4747
2. Create `index.qmd` with `title:` frontmatter.
4848
3. Create empty `references.bib`.
49-
4. Append `_site/` and `.quarto/` to `.gitignore` (don't overwrite).
50-
5. Copy `assets/Makefile` to project root.
49+
4. Append any lines from `assets/gitignore` missing from the project's `.gitignore` (create it if absent; don't overwrite existing entries).
50+
5. Copy `assets/Makefile` to project root, and `assets/quarto-check.sh` to `scripts/quarto-check.sh` (vendored verbatim — the Makefile's `check` target runs it; to update it later, re-copy rather than hand-edit). CI warns when the vendored copy drifts from the canonical one; on that warning, re-copy the asset.
5151
6. Copy `assets/README.md`; fill `{{TITLE}}` and `{{DESCRIPTION}}` from the user.
5252
7. Copy `assets/DEVELOPER.md` to project root. User owns it — only update later with explicit user permission.
5353

5454
### GitHub Pages deploy
5555

56-
1. Copy `assets/docs.yml` to `.github/workflows/docs.yml`.
56+
1. Copy `assets/docs.yml` to `.github/workflows/docs.yml`. It's a thin stub — the build/deploy/preview machinery lives in this repo's reusable workflow (`.github/workflows/workspace-quarto-site.yml`), so scaffolded projects pick up fixes without re-copying. Project-specific quality gates go in the project's `make check` target, which the reusable workflow calls. When updating an existing project to this stub, update its `Makefile` in the same change (the workflow requires a `check` target), and update any branch-protection required-check names to the new contexts (the build check is now reported as `docs / build`) — via `gh api` if the token has admin on the repo, otherwise ask the user.
5757
2. Configure Pages to serve from `gh-pages`:
5858
```bash
5959
gh api repos/{owner}/{repo}/pages -X POST --input - <<'EOF'

plugins/asta-tools/skills/workspace/assets/DEVELOPER.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,15 @@ Edit `.qmd` files on GitHub directly or in any editor.
2525
|---|---|
2626
| `make preview` | live preview on port 4848 |
2727
| `make render` | build to `_site/` |
28+
| `make check` | run the same quality gates CI runs (render + warning validation) |
2829
| `make clean` | wipe build artifacts |
2930
| `make dev` | open VS Code attached to devcontainer |
3031
| `make deployed-url` | print deployed URL (needs auto-deploy below) |
3132

3233
## Auto-deploy (`.github/workflows/docs.yml`)
3334

34-
Every push to `main` and every PR triggers a CI render + deploy. Main: `https://<owner>.github.io/<repo>/`. PRs get a preview URL via bot comment.
35+
Every push to `main` and every PR triggers CI checks + deploy. Main: `https://<owner>.github.io/<repo>/`. PRs get a preview URL via bot comment.
36+
37+
`docs.yml` is a thin stub: the build/deploy/preview machinery is a shared [reusable workflow](https://github.com/allenai/asta-plugins/blob/main/.github/workflows/workspace-quarto-site.yml) maintained centrally, so fixes flow to this repo without re-copying (pin its ref to a tag if you prefer explicit upgrades).
38+
39+
CI runs `make check` — the identical command you can run locally before pushing, so a local pass predicts the CI result. New quality gates belong in the `check` target (or a prerequisite target), not in workflow files, so local and CI can't drift.

plugins/asta-tools/skills/workspace/assets/Makefile

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
1-
.PHONY: preview render clean dev deployed-url
1+
.PHONY: preview render clean dev deployed-url check
22

33
preview:
44
quarto preview --no-browser
55

66
render:
77
quarto render
88

9+
# Run the same quality gates CI runs, in one place so local and CI can't
10+
# drift. CI's docs workflow calls this target — when a project grows a new
11+
# gate, add it here (or as a prerequisite target), never as an inline workflow
12+
# step. The shared render/validate logic is vendored in scripts/quarto-check.sh
13+
# (from the workspace skill; update by re-copying, don't hand-edit).
14+
check:
15+
sh scripts/quarto-check.sh
16+
917
clean:
1018
rm -rf _site .quarto
1119

plugins/asta-tools/skills/workspace/assets/docs.yml

Lines changed: 11 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -6,94 +6,19 @@ on:
66
pull_request:
77
types: [opened, synchronize, reopened, closed]
88

9+
# All build/deploy/preview machinery lives in the shared reusable workflow
10+
# maintained alongside the workspace skill that scaffolded this repo — this
11+
# stub only decides when it runs. Repo-specific quality gates belong in this
12+
# repo's `make check` target (which the shared workflow calls), never here.
13+
#
14+
# The token needs write access for gh-pages pushes and PR preview comments;
15+
# the called workflow's jobs individually downscope to least privilege.
916
permissions:
1017
contents: write
1118
pull-requests: write
1219

13-
concurrency:
14-
group: pages-${{ github.ref }}
15-
cancel-in-progress: true
16-
1720
jobs:
18-
build-and-deploy:
19-
runs-on: ubuntu-latest
20-
steps:
21-
- uses: actions/checkout@v4
22-
23-
- uses: quarto-dev/quarto-actions/setup@v2
24-
25-
- name: Render site
26-
if: github.event.action != 'closed'
27-
run: quarto render 2>&1 | tee quarto-render.log
28-
29-
- name: Validate render output
30-
if: github.event.action != 'closed'
31-
run: |
32-
if [ ! -f _site/index.html ]; then
33-
echo "::error::Quarto render failed — _site/index.html not found"
34-
exit 1
35-
fi
36-
# Fail on Quarto warnings (broken citations, dead links, etc.)
37-
WARNINGS=$(sed 's/\x1b\[[0-9;]*m//g' quarto-render.log | grep -E '\[WARNING\]|^WARN:' || true)
38-
if [ -n "$WARNINGS" ]; then
39-
echo "## ⚠️ Quarto warnings" >> "$GITHUB_STEP_SUMMARY"
40-
echo '```' >> "$GITHUB_STEP_SUMMARY"
41-
echo "$WARNINGS" >> "$GITHUB_STEP_SUMMARY"
42-
echo '```' >> "$GITHUB_STEP_SUMMARY"
43-
exit 1
44-
fi
45-
46-
- name: Deploy to gh-pages
47-
env:
48-
PR_NUMBER: ${{ github.event.pull_request.number }}
49-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
50-
run: |
51-
git config user.name "github-actions[bot]"
52-
git config user.email "github-actions[bot]@users.noreply.github.com"
53-
54-
# Fetch gh-pages or create it
55-
git fetch origin gh-pages:gh-pages 2>/dev/null || git checkout --orphan gh-pages
56-
57-
# Work in a temp directory
58-
tmp=$(mktemp -d)
59-
git worktree add "$tmp" gh-pages
60-
61-
if [ "${{ github.event_name }}" = "push" ]; then
62-
# Main deploy: replace everything except pr-preview/
63-
cd "$tmp"
64-
for f in *; do
65-
[ "$f" = "pr-preview" ] && continue
66-
rm -rf "$f"
67-
done
68-
cp -r "$GITHUB_WORKSPACE/_site/"* "$tmp/"
69-
elif [ "${{ github.event.action }}" = "closed" ]; then
70-
# PR closed: clean up preview
71-
rm -rf "$tmp/pr-preview/pr-$PR_NUMBER"
72-
else
73-
# PR preview: deploy to pr-preview/pr-<N>/
74-
mkdir -p "$tmp/pr-preview/pr-$PR_NUMBER"
75-
rm -rf "$tmp/pr-preview/pr-$PR_NUMBER"/*
76-
cp -r "$GITHUB_WORKSPACE/_site/"* "$tmp/pr-preview/pr-$PR_NUMBER/"
77-
fi
78-
79-
cd "$tmp"
80-
git add -A
81-
git diff --cached --quiet && exit 0 # nothing to commit
82-
git commit -m "Deploy: ${{ github.event_name }} ${{ github.sha }}"
83-
git push origin gh-pages
84-
85-
# Comment on PR with preview link
86-
if [ "${{ github.event_name }}" = "pull_request" ] && [ "${{ github.event.action }}" != "closed" ]; then
87-
REPO="${{ github.repository }}"
88-
OWNER="${REPO%%/*}"
89-
REPO_NAME="${REPO##*/}"
90-
PREVIEW_URL="https://${OWNER}.github.io/${REPO_NAME}/pr-preview/pr-${PR_NUMBER}/"
91-
BODY="📄 Preview: ${PREVIEW_URL}"
92-
# Update or create comment
93-
EXISTING=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --jq '.[] | select(.body | startswith("📄 Preview:")) | .id' | head -1)
94-
if [ -n "$EXISTING" ]; then
95-
gh api "repos/${REPO}/issues/comments/${EXISTING}" -X PATCH -f body="$BODY"
96-
else
97-
gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" -f body="$BODY"
98-
fi
99-
fi
21+
docs:
22+
# Tracks main so fixes flow automatically; pin to a tag (e.g. @v0.101.0)
23+
# if you prefer explicit upgrades.
24+
uses: allenai/asta-plugins/.github/workflows/workspace-quarto-site.yml@main
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Quarto build artifacts (the workspace skill appends any of these lines
2+
# missing from the project's .gitignore).
3+
_site/
4+
.quarto/
5+
quarto-render.*

0 commit comments

Comments
 (0)