new file mode 100644
@@ -0,0 +1,424 @@
+name: Documentation
+
+on:
+ workflow_call:
+
+jobs:
+ docs:
+ runs-on: ubuntu-latest
+
+ permissions:
+ contents: write
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - run: git fetch --tags
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Resolve slot
+ run: |
+ REF="${GITHUB_REF}"
+ if [[ "$REF" =~ ^refs/tags/v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
+ echo "SLOT=releases/v${BASH_REMATCH[1]}" >> $GITHUB_ENV
+ echo "CURRENT_TAG=v${BASH_REMATCH[1]}" >> $GITHUB_ENV
+ echo "IS_RELEASE=true" >> $GITHUB_ENV
+ elif [[ "$REF" =~ ^refs/tags/(.*-rc\..*)$ ]]; then
+ echo "SLOT=release-candidate" >> $GITHUB_ENV
+ echo "CURRENT_TAG=${BASH_REMATCH[1]}" >> $GITHUB_ENV
+ echo "IS_RELEASE=false" >> $GITHUB_ENV
+ elif [[ "$REF" =~ ^refs/tags/(.*-preview\..*)$ ]]; then
+ echo "SLOT=preview" >> $GITHUB_ENV
+ echo "CURRENT_TAG=${BASH_REMATCH[1]}" >> $GITHUB_ENV
+ echo "IS_RELEASE=false" >> $GITHUB_ENV
+ else
+ CURRENT_TAG=$(git for-each-ref \
+ --sort=-creatordate \
+ --format='%(refname:short)' \
+ refs/tags \
+ | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-(rc|preview)\.[0-9]+$' \
+ | head -1)
+
+ if [[ -z "$CURRENT_TAG" ]]; then
+ CURRENT_TAG="HEAD"
+ fi
+
+ if [[ "$CURRENT_TAG" =~ -rc\. ]]; then
+ echo "SLOT=release-candidate" >> $GITHUB_ENV
+ else
+ echo "SLOT=preview" >> $GITHUB_ENV
+ fi
+ echo "CURRENT_TAG=${CURRENT_TAG}" >> $GITHUB_ENV
+ echo "IS_RELEASE=false" >> $GITHUB_ENV
+ fi
+
+ # ── FULL MODE ────────────────────────────────────────────────────────────
+
+ - name: Install DocFX
+ if: env.IS_RELEASE == 'true'
+ run: dotnet tool install -g docfx
+
+ - name: Build XML docs
+ if: env.IS_RELEASE == 'true'
+ run: |
+ dotnet build src/Olav.Cli/Olav.Cli.csproj \
+ -c Release /p:GenerateDocumentationFile=true
+
+ - name: Build DocFX site
+ if: env.IS_RELEASE == 'true'
+ run: docfx docs/docfx.json
+
+ # ── CHANGELOG MODE ───────────────────────────────────────────────────────
+
+ - name: Generate changelog and diff pages
+ if: env.IS_RELEASE == 'false'
+ run: |
+ CURRENT="${{ env.CURRENT_TAG }}"
+
+ LAST_RELEASE=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' \
+ | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
+ | sort -rV \
+ | head -1)
+
+ if [[ -z "$LAST_RELEASE" ]]; then
+ RANGE="$CURRENT"
+ SINCE_LABEL="the beginning"
+ DIFF_BASE="$(git rev-list --max-parents=0 HEAD)"
+ else
+ RANGE="${LAST_RELEASE}..${CURRENT}"
+ SINCE_LABEL="$LAST_RELEASE"
+ DIFF_BASE="$LAST_RELEASE"
+ fi
+
+ mkdir -p docs/_site
+
+ # ── Aggregate file stat table ─────────────────────────────────────────
+ FILE_ROWS=$(git diff --stat "$DIFF_BASE" "$CURRENT" \
+ | grep '|' \
+ | sed 's/^ *//' \
+ | awk '
+ /\|/ {
+ split($0, a, "|");
+ file = a[1];
+ detail = a[2];
+ gsub(/^ +| +$/, "", file);
+ gsub(/^ +/, "", detail);
+ sub(/^[0-9]+ */, "", detail);
+
+ bar = detail;
+ gsub(/[^+\-]/, "", bar);
+
+ ins = (length(bar) > 0) ? split(bar, tmp, "+") - 1 : 0;
+ del = (length(bar) > 0) ? split(bar, tmp, "-") - 1 : 0;
+
+ print "<tr><td class=\"file\">" file "</td><td class=\"bar\">" bar "</td><td class=\"ins\">+" ins "</td><td class=\"del\">-" del "</td></tr>"
+ }
+ ')
+
+ STAT_SUMMARY=$(git diff --stat "$DIFF_BASE" "$CURRENT" | tail -1 | sed 's/^ *//')
+
+ if [[ -z "$FILE_ROWS" ]]; then
+ FILE_ROWS='<tr><td colspan="4" class="empty">No changes found.</td></tr>'
+ fi
+
+ # ── Full diff → diff.html ─────────────────────────────────────────────
+ DIFF_BODY=$(git diff "$DIFF_BASE" "$CURRENT" \
+ -- \
+ ':(exclude)*.csproj' \
+ ':(exclude)*.slnx' \
+ ':(exclude)*.sln' \
+ | sed \
+ -e 's/&/\&/g' \
+ -e 's/</\</g' \
+ -e 's/>/\>/g' \
+ | awk '
+ /^diff --git/ {
+ if (open) print "</div></div>"
+ # extract filename from "diff --git a/foo b/foo"
+ match($0, /b\/(.+)$/, arr)
+ fname = arr[1]
+ print "<div class=\"file-block\">"
+ print "<div class=\"file-header\"><span class=\"file-name\">" fname "</span></div>"
+ print "<div class=\"file-body\">"
+ open = 1
+ next
+ }
+ /^index / { next }
+ /^--- / { next }
+ /^\+\+\+ / { next }
+ /^Binary / { print "<div class=\"binary\">Binary file changed</div>"; next }
+ /^@@/ {
+ print "<div class=\"hunk\">" $0 "</div>"
+ next
+ }
+ /^\+/ { print "<div class=\"add\">" $0 "</div>"; next }
+ /^-/ { print "<div class=\"del\">" $0 "</div>"; next }
+ { print "<div class=\"ctx\">" $0 "</div>" }
+ END { if (open) print "</div></div>" }
+ ')
+
+ cat > docs/_site/diff.html <<DIFFEOF
+ <!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Full Diff — ${CURRENT} | Olav</title>
+ <style>
+ *, *::before, *::after { box-sizing: border-box; }
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ max-width: 1100px; margin: 60px auto; padding: 0 24px;
+ color: #1a1a1a; background: #fff;
+ }
+ .back { font-size: 0.85rem; color: #888; text-decoration: none; display: inline-block; margin-bottom: 32px; }
+ .back:hover { color: #0969da; }
+ header { margin-bottom: 40px; }
+ h1 { font-size: 1.6rem; margin: 0 0 4px; }
+ .subtitle { color: #666; font-size: 0.95rem; margin: 0; }
+
+ .file-block { margin-bottom: 32px; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; }
+ .file-header {
+ padding: 10px 16px; background: #f6f8fa;
+ border-bottom: 1px solid #e5e7eb;
+ font-family: monospace; font-size: 0.85rem;
+ }
+ .file-name { font-weight: 600; color: #1a1a1a; }
+ .file-body { overflow-x: auto; }
+ .file-body div { font-family: monospace; font-size: 0.82rem; padding: 1px 16px; white-space: pre; line-height: 1.5; }
+ .hunk { background: #dbeafe; color: #1e40af; }
+ .add { background: #dcfce7; color: #14532d; }
+ .del { background: #fee2e2; color: #7f1d1d; }
+ .ctx { color: #374151; }
+ .binary { padding: 8px 16px; color: #888; font-style: italic; font-size: 0.85rem; }
+ </style>
+ </head>
+ <body>
+ <a class="back" href="index.html">← Back to changelog</a>
+ <header>
+ <h1>Full Diff · ${CURRENT}</h1>
+ <p class="subtitle">Changes since ${SINCE_LABEL}</p>
+ </header>
+ ${DIFF_BODY}
+ </body>
+ </html>
+ DIFFEOF
+
+ # ── index.html ────────────────────────────────────────────────────────
+ cat > docs/_site/index.html <<IDXEOF
+ <!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Changelog — ${CURRENT} | Olav</title>
+ <style>
+ *, *::before, *::after { box-sizing: border-box; }
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ max-width: 900px; margin: 60px auto; padding: 0 24px;
+ color: #1a1a1a; background: #fff;
+ }
+ header { margin-bottom: 40px; }
+ h1 { font-size: 1.6rem; margin: 0 0 4px; }
+ .subtitle { color: #666; font-size: 0.95rem; margin: 0 0 12px; }
+ .diff-link {
+ display: inline-block; margin-top: 4px;
+ font-size: 0.88rem; color: #0969da; text-decoration: none;
+ }
+ .diff-link:hover { text-decoration: underline; }
+ .back { font-size: 0.85rem; color: #888; text-decoration: none; display: inline-block; margin-bottom: 32px; }
+ .back:hover { color: #0969da; }
+ .summary { font-size: 0.85rem; color: #555; margin-bottom: 24px; font-family: monospace; }
+ table.diff-stat {
+ width: 100%; border-collapse: collapse;
+ font-size: 0.85rem; font-family: monospace;
+ border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;
+ }
+ table.diff-stat thead th {
+ text-align: left; padding: 8px 12px;
+ font-size: 0.75rem; color: #888;
+ border-bottom: 1px solid #e5e7eb;
+ background: #f6f8fa;
+ }
+ table.diff-stat tbody tr:hover { background: #f6f8fa; }
+ table.diff-stat td { padding: 5px 12px; vertical-align: middle; }
+ td.file { color: #1a1a1a; word-break: break-all; }
+ td.bar { font-family: monospace; letter-spacing: -1px; color: #555; }
+ td.ins { color: #1a7f37; font-weight: 600; white-space: nowrap; }
+ td.del { color: #cf222e; font-weight: 600; white-space: nowrap; }
+ .empty { color: #888; font-style: italic; }
+ </style>
+ </head>
+ <body>
+ <a class="back" href="../../">← All versions</a>
+ <header>
+ <h1>Changelog · ${CURRENT}</h1>
+ <p class="subtitle">Changes since ${SINCE_LABEL}</p>
+ <a class="diff-link" href="diff.html">View full diff →</a>
+ </header>
+ <p class="summary">${STAT_SUMMARY}</p>
+ <table class="diff-stat">
+ <thead><tr><th>File</th><th>Changes</th><th>+</th><th>-</th></tr></thead>
+ <tbody>${FILE_ROWS}</tbody>
+ </table>
+ </body>
+ </html>
+ IDXEOF
+
+ # ── SHARED: deploy to gh-pages ───────────────────────────────────────────
+
+ - name: Ensure gh-pages branch exists
+ run: |
+ if ! git ls-remote --exit-code --heads origin gh-pages; then
+ git config user.name "github-actions"
+ git config user.email "actions@github.com"
+ git checkout --orphan gh-pages
+ git rm -rf . --quiet
+ touch .nojekyll
+ git add .nojekyll
+ git commit -m "chore: init gh-pages"
+ git push origin gh-pages
+ git checkout -
+ fi
+
+ - uses: actions/checkout@v4
+ with:
+ ref: gh-pages
+ path: gh-pages
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Deploy slot
+ run: |
+ SLOT="${{ env.SLOT }}"
+ DEST="gh-pages/$SLOT"
+
+ touch gh-pages/.nojekyll
+
+ if [[ "${{ env.IS_RELEASE }}" == "true" && -d "$DEST" ]]; then
+ echo "Release docs for $SLOT already exist — skipping."
+ exit 0
+ fi
+
+ rm -rf "$DEST"
+ mkdir -p "$DEST"
+ cp -r docs/_site/. "$DEST/"
+
+ - name: Update versions manifest
+ if: env.IS_RELEASE == 'true'
+ run: |
+ MANIFEST="gh-pages/versions.json"
+ SLOT="${{ env.SLOT }}"
+ node -e "
+ const fs = require('fs');
+ let versions = [];
+ try { versions = JSON.parse(fs.readFileSync('$MANIFEST', 'utf8')); } catch {}
+ if (!versions.includes('$SLOT')) versions.unshift('$SLOT');
+ fs.writeFileSync('$MANIFEST', JSON.stringify(versions, null, 2));
+ "
+
+ - name: Generate index pages
+ run: |
+ VERSIONS_JSON="gh-pages/versions.json"
+ RELEASES=$(node -e "
+ const fs = require('fs');
+ let versions = [];
+ try { versions = JSON.parse(fs.readFileSync('$VERSIONS_JSON', 'utf8')); } catch {}
+ console.log(versions.map(v =>
+ '<li><a href=\"' + v + '/\">' + v.replace('releases/', '') + '</a></li>'
+ ).join('\n'));
+ ")
+
+ cat > gh-pages/index.html <<'HTMLEOF'
+ <!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Olav Documentation</title>
+ <style>
+ body { font-family: sans-serif; max-width: 800px; margin: 60px auto; padding: 0 24px; color: #1a1a1a; }
+ h1 { font-size: 2rem; margin-bottom: 8px; }
+ p { color: #555; margin-bottom: 32px; }
+ h2 { font-size: 1.1rem; text-transform: uppercase; letter-spacing: 0.05em; color: #888; margin-bottom: 12px; }
+ ul { list-style: none; padding: 0; margin: 0 0 40px; }
+ li { margin-bottom: 8px; }
+ a { color: #0969da; text-decoration: none; font-size: 1rem; }
+ a:hover { text-decoration: underline; }
+ </style>
+ </head>
+ <body>
+ <h1>Olav</h1>
+ <p>Documentation for all versions of Olav.</p>
+ <h2>Releases</h2>
+ <ul>
+ RELEASES_PLACEHOLDER
+ </ul>
+ <h2>Pre-release</h2>
+ <ul>
+ <li><a href="release-candidate/">Release Candidate (changelog)</a></li>
+ <li><a href="preview/">Preview (changelog)</a></li>
+ </ul>
+ </body>
+ </html>
+ HTMLEOF
+
+ if [[ -z "$RELEASES" ]]; then
+ RELEASES='<li><em>No releases yet.</em></li>'
+ fi
+ sed -i "s|RELEASES_PLACEHOLDER|$RELEASES|" gh-pages/index.html
+
+ mkdir -p gh-pages/releases
+ RELEASE_LINKS=$(ls -1d gh-pages/releases/v*/ 2>/dev/null \
+ | sed 's|gh-pages/releases/||;s|/||' \
+ | sort -rV \
+ | sed 's|.*|<li><a href="&/">&</a></li>|')
+
+ cat > gh-pages/releases/index.html <<'HTMLEOF'
+ <!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Releases | Olav</title>
+ <style>
+ body { font-family: sans-serif; max-width: 800px; margin: 60px auto; padding: 0 24px; color: #1a1a1a; }
+ h1 { font-size: 2rem; margin-bottom: 8px; }
+ p { color: #555; margin-bottom: 32px; }
+ ul { list-style: none; padding: 0; }
+ li { margin-bottom: 8px; }
+ a { color: #0969da; text-decoration: none; font-size: 1rem; }
+ a:hover { text-decoration: underline; }
+ .back { font-size: 0.9rem; color: #888; margin-bottom: 32px; display: block; }
+ </style>
+ </head>
+ <body>
+ <a class="back" href="../">← Back</a>
+ <h1>Releases</h1>
+ <ul>
+ RELEASE_LINKS_PLACEHOLDER
+ </ul>
+ </body>
+ </html>
+ HTMLEOF
+
+ if [[ -z "$RELEASE_LINKS" ]]; then
+ RELEASE_LINKS='<li><em>No releases yet.</em></li>'
+ fi
+ sed -i "s|RELEASE_LINKS_PLACEHOLDER|$RELEASE_LINKS|" gh-pages/releases/index.html
+
+ - name: Commit and push gh-pages
+ run: |
+ cd gh-pages
+ git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}
+ git config user.name "github-actions"
+ git config user.email "actions@github.com"
+ git add .
+ git diff --cached --quiet && echo "Nothing to commit" && exit 0
+ git commit -m "docs: deploy ${{ env.SLOT }}"
+ git push origin gh-pages