stagit

static page generator for git
git clone https://github.com/michaelneuper/stagit.git
README | Files | Log | Refs | LICENSE

commit 3448e6d96b4c7b29f81ed30b6d00edb237eeb6a0
parent 23cda86261cfb92e775ac8640079d4b28a7921a5
Author: Michael Neuper <michael@michaelneuper.com>
Date:   Sun, 25 Oct 2026 21:15:53 +0200

add build script and Netlify workflow for git.michaelneuper.com

Diffstat:
A.github/workflows/mirror.yml | 47+++++++++++++++++++++++++++++++++++++++++++++++
AREADME.md | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abuild.sh | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexclude | 2++
4 files changed, 228 insertions(+), 0 deletions(-)

diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml @@ -0,0 +1,47 @@ +name: mirror + +on: + push: + branches: [main] + schedule: + - cron: "23 */6 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: deploy + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install libgit2 + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends libgit2-dev + + - name: Build stagit + run: make + + - name: Generate site + run: ./build.sh + env: + GH_TOKEN: ${{ github.token }} + GITHUB_USER: ${{ github.repository_owner }} + SITE_URL: https://git.michaelneuper.com/ + + # netlify-cli needs Node 22.13 or newer. + - uses: actions/setup-node@v7 + with: + node-version: "22" + + - name: Deploy to Netlify + run: npx --yes netlify-cli@27.12.0 deploy --prod --no-build --dir public + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} diff --git a/README.md b/README.md @@ -0,0 +1,80 @@ +# stagit + +My fork of [stagit](https://codemadness.org/git/stagit/), plus the scripts +that use it to publish <https://git.michaelneuper.com>, a static, read-only +mirror of my public GitHub repos. The upstream README is in `README`. + +## Changes from upstream + +- cgit-flavoured `style.css`, and my own logo and favicon. +- The Makefile also finds libraries installed by Homebrew. + +To see only the code changes: `git diff 1.3 -- '*.c' Makefile`. + +## Building the site + +Needs libgit2 and the GitHub CLI (`brew install libgit2 gh`). + + ./build.sh + python3 -m http.server -d public + +`build.sh` builds stagit with `make`, lists every public, non-fork, +non-empty repo of the account `gh` is logged in to, fetches it into +`work/repos`, and writes the site to `public`. Repos named in `exclude` are +skipped. Environment variables: + +- `GITHUB_USER`: account to mirror instead of the logged-in one. +- `SITE_URL`: public URL with trailing slash, used to make the Atom feed + links absolute. +- `SERVE_GIT=1`: also publish the bare repos, so that + `git clone https://git.michaelneuper.com/<repo>.git` works over git's dumb + HTTP protocol. It's off in the workflow because the website repo's + packfile is over 100 MB, and Netlify doesn't handle files that large well. + +To try a change on a single repo without a full build: + + make + mkdir -p /tmp/out && cd /tmp/out + ~/Developer/stagit/stagit ~/Developer/stagit/work/repos/cube.git + +## Deploying + +`.github/workflows/mirror.yml` builds the site and deploys it to Netlify on +every push to `main`, every 6 hours, and on demand. One-time setup: + +1. Create an empty Netlify project and note the project ID it prints: + + npx netlify-cli login + npx netlify-cli sites:create --name <name> --disable-linking + +2. In the Netlify UI, open the project's domain management and add + `git.michaelneuper.com`. michaelneuper.com is on Netlify DNS, so the + record and HTTPS certificate are set up automatically. +3. Create a personal access token under User settings → Applications. +4. Push this repo to GitHub and add both as secrets: + + gh secret set NETLIFY_AUTH_TOKEN + gh secret set NETLIFY_SITE_ID + +5. Run the workflow from the Actions tab, or push to `main`. + +GitHub disables scheduled workflows in public repos after 60 days without +activity in the repo. That doesn't apply if the repo is private. + +## Updating from upstream + +Upstream is only reachable over the git protocol: + + git remote add upstream git://git.codemadness.org/stagit + git fetch upstream --tags + git merge <new tag> + +Merge rather than rebase, so `main` never needs a force push. Conflicts in +`style.css`, `logo.png` and `favicon.png` resolve to ours. + +## Notes + +- Every run regenerates the site from scratch. stagit never rewrites an + existing commit page, so incremental output would go stale after a force + push or a description change. Netlify only uploads files whose contents + changed, so redeploys stay small. diff --git a/build.sh b/build.sh @@ -0,0 +1,99 @@ +#!/bin/sh +# Mirror public GitHub repos and render them as static HTML with stagit. +# +# Environment: +# GITHUB_USER account to mirror (default: the account gh is logged in as) +# SITE_URL public URL of the site, with trailing slash; makes the links +# in the Atom feeds absolute (optional) +# SERVE_GIT set to 1 to also publish the bare repos, so that +# `git clone ${SITE_URL}<name>.git` works over dumb HTTP +# +# Builds stagit if needed, fetches repos into ./work/repos and writes the +# site to ./public. +set -eu + +root=$(cd "$(dirname "$0")" && pwd) +work=$root/work +out=$root/public + +user=${GITHUB_USER:-$(gh api user --jq .login)} +site_url=${SITE_URL:-} +serve_git=${SERVE_GIT:-0} + +copyassets() { + cp "$root/style.css" "$root/logo.png" "$root/favicon.png" "$1" +} + +(cd "$root" && make >/dev/null) + +mkdir -p "$work/repos" +rm -rf "$out" +mkdir -p "$out" +copyassets "$out" + +# One line per non-empty, public, non-fork repo: +# name<TAB>default branch<TAB>description +gh repo list "$user" --visibility public --source --limit 1000 \ + --json name,defaultBranchRef,description,isEmpty \ + --jq '.[] | select(.isEmpty | not) + | [.name, .defaultBranchRef.name, (.description // "" | gsub("\\s+"; " "))] + | join("\t")' > "$work/repos.tsv" + +tab=$(printf '\t') +: > "$work/index.lst" +while IFS=$tab read -r name branch desc <&3; do + if grep -qxF "$name" "$root/exclude" 2>/dev/null; then + continue + fi + echo "$name" + repo=$work/repos/$name.git + + # Track only branches and tags; a --mirror clone would also pull in + # GitHub's refs/pull/*. + if [ ! -d "$repo" ]; then + git init -q --bare "$repo" + git -C "$repo" remote add origin "https://github.com/$user/$name.git" + git -C "$repo" config remote.origin.fetch '+refs/heads/*:refs/heads/*' + git -C "$repo" config --add remote.origin.fetch '+refs/tags/*:refs/tags/*' + fi + git -C "$repo" fetch -q --prune origin + git -C "$repo" symbolic-ref HEAD "refs/heads/$branch" + + # Metadata read by stagit and stagit-index. No trailing newline, or it + # ends up inside <title>. + printf '%s' "$desc" > "$repo/description" + printf '%s' "$user" > "$repo/owner" + if [ "$serve_git" = 1 ]; then + printf '%s' "$site_url$name.git" > "$repo/url" + else + printf '%s' "https://github.com/$user/$name.git" > "$repo/url" + fi + + # Pages are regenerated from scratch every run: stagit never rewrites an + # existing commit page, so incremental output goes stale on force pushes + # or description changes. + mkdir -p "$out/$name" + ( + cd "$out/$name" + "$root/stagit" ${site_url:+-u "$site_url$name/"} "$repo" + # stagit writes index.html from the README; without one, open + # on the log. + [ -f index.html ] || cp log.html index.html + copyassets . + ) + + if [ "$serve_git" = 1 ]; then + git -C "$repo" update-server-info + cp -R "$repo" "$out/$name.git" + fi + + echo "$(git -C "$repo" log -1 --format=%ct) $repo" >> "$work/index.lst" +done 3< "$work/repos.tsv" + +# Index page, most recently committed to first. +sort -rn -o "$work/index.lst" "$work/index.lst" +set -- +while read -r _ repo; do + set -- "$@" "$repo" +done < "$work/index.lst" +"$root/stagit-index" "$@" > "$out/index.html" diff --git a/exclude b/exclude @@ -0,0 +1,2 @@ +# Repos to leave out of the mirror, one name per line. +# Forks, private and empty repos are always skipped.