From 041a2cbfe798c5af6a38ac44cf96cf28cb0a1320 Mon Sep 17 00:00:00 2001 From: "v.savkin" Date: Sun, 4 Oct 2026 20:57:55 +0600 Subject: [PATCH] feat: each sample branch draws its CI by day into its README --- .samples/ci-svg.py | 208 +++++++++++++++++++++++++++++++++++++++++++ .samples/generate.sh | 13 ++- .samples/report.sh | 3 + 3 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 .samples/ci-svg.py diff --git a/.samples/ci-svg.py b/.samples/ci-svg.py new file mode 100644 index 0000000..bd69ccc --- /dev/null +++ b/.samples/ci-svg.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Draws the CI of a sample branch by day, as the samples page of the template's site draws it: + + ci-svg.py + +The README of a branch shows the picture: GitHub shows images there without running a script, so the grid +of the site cannot be the page itself. The same data (every run of every version in the folder), the same +rules and the same colours as site/samples.js and samples.css of DotNetSolutionKit: + +- a day keeps the result of the branch's last run; a pass fades to ice and a failure rots to dark red over + 30 days without a run, the colours mixed in OKLab as the site's color-mix does; +- 52 weeks back, the weeks to come fading out, Monday at the top; +- light and dark follow the reader's colour scheme. + +Days are UTC: the picture is drawn once, for every reader. +""" +import datetime as dt +import glob +import json +import os +import sys + +FADE_DAYS = 30 +PAST_WEEKS = 52 +FUTURE_WEEKS = 8 +CELL, GAP = 11, 3 +LEFT, TOP = 34, 46 + +THEMES = { + "dark": {"ok": "#3fb950", "bad": "#e08d79", "sleep": "#8ec5e0", "rot": "#4a1010", "empty": "#141d33", + "ground": "#0e1526", "text": "#e6ecf5", "muted": "#93a2bc"}, + "light": {"ok": "#1f9d4c", "bad": "#cf3b2e", "sleep": "#74b3d6", "rot": "#5c1414", "empty": "#eef2fa", + "ground": "#ffffff", "text": "#14203a", "muted": "#5a6a8a"}, +} + + +# ---- colour: sRGB <-> OKLab, as CSS color-mix(in oklab, a p%, b) ------------------------------------- +def _to_linear(c): + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + +def _to_srgb(c): + c = max(0.0, min(1.0, c)) + return 12.92 * c if c <= 0.0031308 else 1.055 * c ** (1 / 2.4) - 0.055 + + +def _oklab(hex_colour): + r, g, b = (_to_linear(int(hex_colour[i:i + 2], 16) / 255) for i in (1, 3, 5)) + l = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) ** (1 / 3) + m = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) ** (1 / 3) + s = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) ** (1 / 3) + return (0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s) + + +def _hex(lab): + L, a, b = lab + l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3 + m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3 + s = (L - 0.0894841775 * a - 1.2914855480 * b) ** 3 + rgb = (4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s) + return "#" + "".join(f"{round(_to_srgb(c) * 255):02x}" for c in rgb) + + +def mix(towards, share, base): + """color-mix(in oklab, towards share, base).""" + a, b = _oklab(towards), _oklab(base) + return _hex(tuple(x * share + y * (1 - share) for x, y in zip(a, b))) + + +# ---- the days ------------------------------------------------------------------------------------------- +def runs_of(folder): + runs = [] + for path in glob.glob(os.path.join(folder, "*.json")): + if os.path.basename(path) == "index.json": + continue + report = json.load(open(path, encoding="utf-8")) + runs += [dict(run, version=report.get("version")) for run in report.get("runs", [])] + return sorted((r for r in runs if r.get("started")), key=lambda r: r["started"]) + + +def day_of(stamp): + return dt.date.fromisoformat(stamp[:10]) + + +def cells(runs, today): + start = today - dt.timedelta(days=7 * PAST_WEEKS + today.weekday()) + end = today + dt.timedelta(days=7 * FUTURE_WEEKS + (6 - today.weekday())) + last, i, n, out = None, 0, 0, [] + d = start + while d <= end: + cell = {"week": n // 7, "weekday": n % 7, "day": d} + if d > today: + cell["future"] = (d - today).days + else: + while i < len(runs) and day_of(runs[i]["started"]) <= d: + last, i = runs[i], i + 1 + if last: + cell["ok"] = last.get("conclusion") == "success" + cell["age"] = (d - day_of(last["started"])).days + out.append(cell) + d += dt.timedelta(days=1) + n += 1 + return out + + +def fill(cell, theme): + t = THEMES[theme] + if "future" in cell: + return t["empty"], max(0.15, 1 - cell["future"] / (7 * FUTURE_WEEKS)) + if "ok" not in cell: + return t["empty"], 1.0 + fade = min(cell["age"] / FADE_DAYS, 1) + return (mix(t["sleep"], fade, t["ok"]) if cell["ok"] else mix(t["rot"], fade, t["bad"])), 1.0 + + +# ---- the picture ---------------------------------------------------------------------------------------- +def svg(branch, runs, today): + grid = cells(runs, today) + weeks = grid[-1]["week"] + 1 + width = LEFT + weeks * (CELL + GAP) + 12 + legend_y = TOP + 7 * (CELL + GAP) + 18 + height = legend_y + 26 + + last = runs[-1] if runs else None + if last: + tests = last.get("tests") or {} + cov = last.get("coverage") or {} + result = "passed" if last.get("conclusion") == "success" else "failed" + facts = (f"last run {last['started'][:10]}: {result}, tests {tests.get('passed', 0)} / {tests.get('total', 0)}" + + (f", coverage {cov.get('line')}% lines, {cov.get('branch')}% branches" if cov else "")) + else: + facts = "no run yet" + + css = ["text{font:11px system-ui,-apple-system,'Segoe UI',sans-serif}", + ".t{font-size:13px;font-weight:600}"] + for theme in ("light", "dark"): + t = THEMES[theme] + rules = [f".g{{fill:{t['ground']}}}", f".x{{fill:{t['text']}}}", f".m{{fill:{t['muted']}}}"] + block = "\n".join(rules) + css.append(block if theme == "light" else "@media (prefers-color-scheme: dark){" + block + "}") + + # one class per colour of a theme: a cell names its light colour's class and its dark one's + light = {c: k for k, c in enumerate(sorted({fill(c, "light")[0] for c in grid}))} + dark = {c: k for k, c in enumerate(sorted({fill(c, "dark")[0] for c in grid}))} + css_dark_alias = [] + body = [f'', + f'CI of {branch} by day', + f'{facts}'] + for label, row in (("Mon", 0), ("Wed", 2), ("Fri", 4)): + body.append(f'{label}') + shown = set() + for cell in grid: + x = LEFT + cell["week"] * (CELL + GAP) + y = TOP + cell["weekday"] * (CELL + GAP) + lc, op = fill(cell, "light") + dc, _ = fill(cell, "dark") + cls = f"l{light[lc]}d{dark[dc]}" + if cls not in shown: + shown.add(cls) + css_dark_alias.append((cls, light[lc], dark[dc])) + title = cell["day"].isoformat() + if "ok" in cell: + title += (" passed" if cell["ok"] else " failed") + (f", last run {cell['age']} days before" if cell["age"] else "") + body.append(f'{title}') + + # the legend: what a pass and a failure become without a run + lx = LEFT + for label, colours in (("passed", ("ok", "sleep")), ("failed", ("bad", "rot"))): + body.append(f'{label}') + lx += 44 + for k in range(5): + share = k / 4 + body.append(f'') + for theme in ("light", "dark"): + t = THEMES[theme] + colour = mix(t[colours[1]], share, t[colours[0]]) + rule = f".L{label}{k}{{fill:{colour}}}" + css.append(rule if theme == "light" else "@media (prefers-color-scheme: dark){" + rule + "}") + lx += CELL + GAP + body.append(f'over {FADE_DAYS} days without a run') + lx += 190 + + for cls, lk, dk in css_dark_alias: + lfill = sorted({fill(c, "light")[0] for c in grid})[lk] + dfill = sorted({fill(c, "dark")[0] for c in grid})[dk] + css.append(f".{cls}{{fill:{lfill}}}") + css.append("@media (prefers-color-scheme: dark){." + cls + "{fill:" + dfill + "}}") + + return (f'\n' + f'\n' + "\n".join(body) + "\n\n") + + +def main(): + if len(sys.argv) != 4: + raise SystemExit(__doc__) + folder, branch, out = sys.argv[1:] + today = dt.datetime.now(dt.timezone.utc).date() + open(out, "w", encoding="utf-8", newline="\n").write(svg(branch, runs_of(folder), today)) + + +if __name__ == "__main__": + main() diff --git a/.samples/generate.sh b/.samples/generate.sh index e05e83d..226bc1e 100644 --- a/.samples/generate.sh +++ b/.samples/generate.sh @@ -45,6 +45,10 @@ about=$(jq -r --arg b "$branch" '.[] | select(.branch == $b) | .about' "$here/va echo echo "$about" echo + # The report of the branch's CI draws it after each run; a branch generated for the first time shows it + # from its first run on. + echo "[![CI of $branch by day](reports/$branch/ci.svg)](https://dnsk.sawking.tech/samples.html#$branch)" + echo if [ "$(jq -r --arg b "$branch" '.[] | select(.branch == $b) | .ref // empty' "$here/variants.json")" ]; then echo "Generated from DotNetSolutionKit $tag at [${commit:0:7}](https://github.com/$template_repo/commit/$commit), not a release, with:" else @@ -68,7 +72,7 @@ done if [ "$(jq -r --arg b "$branch" '.[] | select(.branch == $b) | .default // false' "$here/variants.json")" = "true" ]; then mkdir -p .samples .github/workflows - cp "$here/generate.sh" "$here/report.sh" "$here/variants.json" .samples/ + cp "$here/generate.sh" "$here/report.sh" "$here/ci-svg.py" "$here/variants.json" .samples/ cp "$here/../.github/workflows/regenerate.yml" "$here/../.github/workflows/report.yml" .github/workflows/ { echo @@ -79,11 +83,16 @@ if [ "$(jq -r --arg b "$branch" '.[] | select(.branch == $b) | .default // false repo="https://github.com/${GITHUB_REPOSITORY:-sawking-tech/DotNetSolutionKit.Samples}" jq -r --arg r "$repo" '.[] | "| [`\(.branch)`](\($r)/tree/\(.branch)) | \(.about) | [![CI](\($r)/actions/workflows/ci.yml/badge.svg?branch=\(.branch))](\($r)/actions/workflows/ci.yml?query=branch%3A\(.branch)) |"' "$here/variants.json" echo + echo "The CI of each branch by day, as [the samples page](https://dnsk.sawking.tech/samples.html) shows it:" + echo + raw="https://raw.githubusercontent.com/${GITHUB_REPOSITORY:-sawking-tech/DotNetSolutionKit.Samples}" + jq -r --arg raw "$raw" '.[] | "[![CI of \(.branch) by day](\($raw)/\(.branch)/reports/\(.branch)/ci.svg)](https://dnsk.sawking.tech/samples.html#\(.branch))\n"' "$here/variants.json" echo "Once a day [regenerate](.github/workflows/regenerate.yml) checks the template's master: what reaches" echo "master is a release, and every release branch is generated again from a new one, pushed, and its CI runs." echo "nightly follows dev instead, where the next release is built: it is generated again when dev has moved." echo "After each CI run of a branch, [report](.github/workflows/report.yml) writes the run into the branch's" - echo "reports// folder: tests, coverage, time and the branch's files, one file per release." + echo "reports// folder: tests, coverage, time and the branch's files, one file per release, and" + echo "ci.svg, the picture of its days above." echo "The automation is in [.samples](.samples)." } >> README.md fi diff --git a/.samples/report.sh b/.samples/report.sh index 5c329ea..2cdb258 100644 --- a/.samples/report.sh +++ b/.samples/report.sh @@ -7,6 +7,7 @@ # The reports live in reports//, so two branches merged together keep each other's reports. # reports//.json keeps every run of the branch generated from that version (a release tag, # or the template's commit for nightly) and the branch's files; .md is the same for a reader. +# reports//ci.svg draws the branch's runs by day for its README (ci-svg.py). # reports//index.json lists the reports with their last run: the site reads it from # raw.githubusercontent.com, which cannot list a folder. Reports an earlier run left directly in reports/ # are moved into the branch's folder. Needs gh with access to the run, its log and its coverage artifact. @@ -62,4 +63,6 @@ mv "$work/report.json" "$file" } > "$dir/$version.md" jq -s 'map(select(.runs | length > 0) | {version, file: "\(.version).json", runs: (.runs | length), last: (.runs[-1] | {conclusion, started, tests, coverage, seconds})}) | sort_by(.last.started) | reverse' $(ls "$dir"/*.json | grep -v '/index.json$') > "$dir/index.json" +# The same days as a picture, for the README: GitHub shows an image there, not the site's script. +python3 "$(dirname "$0")/ci-svg.py" "$dir" "$branch" "$dir/ci.svg" echo "$file: $(jq -c '.runs[-1] | {conclusion, tests, coverage, seconds}' "$file")"