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 | +++++++++++++++++++++++++++++++++++++++++++++++ |
| A | README.md | | | 80 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | build.sh | | | 99 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | exclude | | | 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.