From 22fdaa94bc409bdc3666ec7b70b8c8008a952894 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Tue, 20 Oct 2026 16:42:05 +0200 Subject: [PATCH] feat(docs): track GHCR package downloads separately from release stats DPM installs via dpm install are not visible in GitHub Release asset counts; scrape the public Packages page into its own history, charts, and daily workflow so adoption metrics stay separate. --- .github/workflows/package-stats.yml | 92 ++++++++ README.md | 26 ++- scripts/package-stats-monthly.jq | 37 ++++ scripts/package-stats.sh | 258 +++++++++++++++++++++++ scripts/package-stats_test.sh | 122 +++++++++++ scripts/testdata/package-stats-page.html | 16 ++ 6 files changed, 542 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/package-stats.yml create mode 100644 scripts/package-stats-monthly.jq create mode 100755 scripts/package-stats.sh create mode 100755 scripts/package-stats_test.sh create mode 100644 scripts/testdata/package-stats-page.html diff --git a/.github/workflows/package-stats.yml b/.github/workflows/package-stats.yml new file mode 100644 index 00000000..ba24b645 --- /dev/null +++ b/.github/workflows/package-stats.yml @@ -0,0 +1,92 @@ +name: Package stats + +# Regenerate GHCR package download-statistics charts for DPM installs. +# Scrapes the public Packages page (no read:packages token required). +# Separate from release-stats.yml — never writes release-downloads-* files. +# +# Generated files are published to the `release-stats-data` branch (not +# `main`) under the package-downloads-* prefix. The README embeds them via +# raw.githubusercontent.com URLs because the `main` branch ruleset requires +# all changes to go through a pull request and this bot commit can't satisfy +# that. + +on: + workflow_dispatch: + schedule: + # Daily at 06:47 UTC (offset from release-stats at 06:17). + - cron: "47 6 * * *" + +permissions: + contents: write + +concurrency: + group: package-stats + cancel-in-progress: false + +jobs: + regenerate: + name: Regenerate package download-stats charts + runs-on: self-hosted + timeout-minutes: 10 + steps: + - name: Check out repository + # actions/checkout@v7.0.0 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + + - name: Seed history from release-stats-data branch + run: | + set -euo pipefail + history_file="docs/assets/package-downloads-history.jsonl" + mkdir -p docs/assets + if git fetch origin release-stats-data:refs/remotes/origin/release-stats-data 2>/dev/null; then + git show origin/release-stats-data:"${history_file}" > "${history_file}" 2>/dev/null || true + fi + + - name: Generate charts + append daily snapshot + run: bash scripts/package-stats.sh + + - name: Publish package-downloads-* to release-stats-data branch + run: | + set -euo pipefail + files=( + docs/assets/package-downloads-monthly.svg + docs/assets/package-downloads.md + docs/assets/package-downloads-history.jsonl + ) + + # Stash generated files before switching branches — + # release-stats-data's tree is unrelated to main's. + tmp="$(mktemp -d)" + for f in "${files[@]}"; do + mkdir -p "${tmp}/$(dirname "${f}")" + cp "${f}" "${tmp}/${f}" + done + for f in "${files[@]}"; do + if git ls-files --error-unmatch "${f}" >/dev/null 2>&1; then + git checkout -- "${f}" + else + rm -f "${f}" + fi + done + + if git fetch origin release-stats-data:refs/remotes/origin/release-stats-data; then + git checkout -B release-stats-data refs/remotes/origin/release-stats-data + else + git checkout --orphan release-stats-data + git reset --hard + fi + + for f in "${files[@]}"; do + mkdir -p "$(dirname "${f}")" + cp "${tmp}/${f}" "${f}" + done + + git add "${files[@]}" + if git diff --cached --quiet; then + echo "Package stats already up to date." + exit 0 + fi + git -c user.name="github-actions[bot]" \ + -c user.email="github-actions[bot]@users.noreply.github.com" \ + commit -m "chore: refresh package download stats" + git push origin release-stats-data diff --git a/README.md b/README.md index 63e8104a..ded3a90c 100644 --- a/README.md +++ b/README.md @@ -120,18 +120,26 @@ For general Canton and Daml questions, use the ## Download statistics -Release download counts aggregated from -[`bitdynamics-ab/canton-devkit`](https://github.com/bitdynamics-ab/canton-devkit/releases) -and -[`bitdynamics-ab/homebrew-canton-devkit`](https://github.com/bitdynamics-ab/homebrew-canton-devkit/releases) -(merged by tag). Charts refresh daily via -[`release-stats.yml`](.github/workflows/release-stats.yml); checksum -files are excluded from the counts. Exact numbers: +### Standalone installs + +Release asset downloads only (Homebrew formula tarballs and direct GitHub +Release downloads). DPM installs via `dpm install` are counted separately +below. Charts refresh daily via +[`release-stats.yml`](.github/workflows/release-stats.yml). Exact numbers: [release-downloads.md](https://github.com/bitdynamics-ab/canton-devkit/blob/release-stats-data/docs/assets/release-downloads.md). -Total downloads per release over time +![Monthly release downloads](https://raw.githubusercontent.com/bitdynamics-ab/canton-devkit/release-stats-data/docs/assets/release-downloads-monthly.svg) + +![All-time downloads per platform](https://raw.githubusercontent.com/bitdynamics-ab/canton-devkit/release-stats-data/docs/assets/release-downloads-by-platform.svg) + +### DPM package downloads + +GHCR package pulls for `canton-devkit` (the `dpm install` path). Tracked +separately from standalone release downloads. Charts refresh daily via +[`package-stats.yml`](.github/workflows/package-stats.yml). Exact numbers: +[package-downloads.md](https://github.com/bitdynamics-ab/canton-devkit/blob/release-stats-data/docs/assets/package-downloads.md). -All-time downloads per platform +![Monthly DPM package downloads](https://raw.githubusercontent.com/bitdynamics-ab/canton-devkit/release-stats-data/docs/assets/package-downloads-monthly.svg) ## Contributing diff --git a/scripts/package-stats-monthly.jq b/scripts/package-stats-monthly.jq new file mode 100644 index 00000000..663ca9a1 --- /dev/null +++ b/scripts/package-stats-monthly.jq @@ -0,0 +1,37 @@ +# Monthly download counts from cumulative daily snapshots. +# +# Input: JSON array of { date, total, ... } (use jq -s on a JSONL history file). +# Output: [ { month: "YYYY-MM", downloads: N }, ... ] oldest first. +# Missing calendar months between the first and last observed delta month are filled with 0. +# The first snapshot is baseline only and contributes no delta. Negative steps clamp to 0. + +def year_month: + .[0:7]; + +def month_index: + ((.[0:4] | tonumber) * 12) + (.[5:7] | tonumber); + +def from_month_index: + ((. - 1) / 12 | floor) as $y + | ((. - 1) % 12 + 1) as $m + | ($m | tostring | if length == 1 then "0\(.)" else . end) as $mm + | "\($y)-\($mm)"; + +sort_by(.date) +| . as $rows +| [ range(1; length) + | { month: ($rows[.].date | year_month), + idx: ($rows[.].date | year_month | month_index), + delta: ([($rows[.].total - $rows[.-1].total), 0] | max) } ] +| group_by(.month) +| map({ month: .[0].month, idx: .[0].idx, downloads: (map(.delta) | add) }) +| . as $observed +| if ($observed | length) == 0 then [] + else + ($observed | map(.idx) | min) as $first + | ($observed | map(.idx) | max) as $last + | ($observed | map({(.month): .downloads}) | add) as $by_month + | [ range($first; $last + 1) + | from_month_index as $m + | { month: $m, downloads: ($by_month[$m] // 0) } ] + end diff --git a/scripts/package-stats.sh b/scripts/package-stats.sh new file mode 100755 index 00000000..2a126ff0 --- /dev/null +++ b/scripts/package-stats.sh @@ -0,0 +1,258 @@ +#!/usr/bin/env bash +# +# package-stats.sh — generate GHCR package download-statistics charts. +# +# Scrapes the public GitHub Packages container page for canton-devkit (no +# read:packages token required), parses the cumulative Total downloads figure +# and the last-30-day sparkline, and emits static SVG + markdown for the +# README. Separate from scripts/release-stats.sh (standalone release assets). +# +# It appends a daily snapshot to a history file so a download-over-time series +# accumulates. On first run (empty/short history), the sparkline is used to +# backfill prior end-of-day cumulative totals. +# +# Data source (default): +# https://github.com/bitdynamics-ab/canton-devkit/pkgs/container/canton-devkit +# +# Outputs (into docs/assets/): +# package-downloads-monthly.svg — new downloads per calendar month (line; last 6 months) +# package-downloads.md — total + monthly summary table +# package-downloads-history.jsonl — appended daily snapshot (total + last30Days) +# +# Environment: +# PACKAGE_URL package page URL (default: bitdynamics-ab canton-devkit container) +# PACKAGE_HTML_FILE if set, read HTML from this file instead of curling PACKAGE_URL +# OUT_DIR output directory (default: docs/assets) +# SNAPSHOT_DATE override snapshot date, UTC YYYY-MM-DD (default: today; for testing) +# +# Requires: jq, curl (unless PACKAGE_HTML_FILE is set). + +set -euo pipefail + +PACKAGE_URL="${PACKAGE_URL:-https://github.com/bitdynamics-ab/canton-devkit/pkgs/container/canton-devkit}" + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/.." && pwd)" +OUT_DIR="${OUT_DIR:-${repo_root}/docs/assets}" + +command -v jq >/dev/null || { echo "error: jq is required" >&2; exit 1; } + +mkdir -p "${OUT_DIR}" + +# --- Fetch package HTML --------------------------------------------------- +fetch_html() { + if [ -n "${PACKAGE_HTML_FILE:-}" ]; then + cat "${PACKAGE_HTML_FILE}" + return 0 + fi + command -v curl >/dev/null || { echo "error: curl is required" >&2; exit 1; } + curl -sSfL \ + -H "Accept: text/html" \ + -A "canton-devkit-package-stats/1.0" \ + "${PACKAGE_URL}" +} + +html="$(fetch_html)" +if [ -z "${html}" ]; then + echo "error: empty package page from ${PACKAGE_URL}" >&2 + exit 1 +fi + +# --- Parse total + sparkline ---------------------------------------------- +# Total downloads lives in:

1.29K

under "Total downloads". +# jq 1.6 scan() returns positional capture arrays, not named objects. +parsed="$(printf '%s' "${html}" | jq -Rs ' + . as $html + | ($html | capture("Total downloads\\s*

[0-9]+)\">"; "m") // null) as $t + | if $t == null then error("could not parse Total downloads from package page") else . end + | ($html + | [scan("data-merge-count=\"([0-9]+)\"\\s*data-date=\"([0-9]{4}-[0-9]{2}-[0-9]{2})\"")] + | map({count: (.[0] | tonumber), date: .[1]})) as $spark_a + | ($html + | [scan("data-date=\"([0-9]{4}-[0-9]{2}-[0-9]{2})\"[\\s\\S]{0,200}?data-merge-count=\"([0-9]+)\"")] + | map({date: .[0], count: (.[1] | tonumber)})) as $spark_b + | (if ($spark_a | length) > 0 then $spark_a else $spark_b end) as $spark + | { + total: ($t.total | tonumber), + sparkline: ($spark | sort_by(.date) + | group_by(.date) + | map({date: .[0].date, count: (map(.count) | max)})), + last30Days: (($spark | map(.count) | add) // 0) + } +')" + +total="$(printf '%s' "${parsed}" | jq -r '.total')" +last30="$(printf '%s' "${parsed}" | jq -r '.last30Days')" +echo "parsed total=${total} last30Days=${last30}" >&2 + +# --- SVG helpers (line chart only) ---------------------------------------- +svg_escape() { printf '%s' "$1" | sed 's/&/\&/g; s//\>/g'; } + +nice_max() { + awk -v v="$1" 'BEGIN{ if (v < 5) v = 5; print (v == int(v)) ? v : int(v)+1 }' +} + +# $1 = output file, $2 = title, $3 = x-axis caption, +# $4 = JSON { labels: [], series: [ {name, color, values:[]} ] } +render_line_chart() { + local out="$1" title="$2" xlabel="$3" data="$4" + + local W=760 H=380 + local ml=56 mr=180 mt=48 mb=64 + local pw ph + pw=$(( W - ml - mr )) + ph=$(( H - mt - mb )) + + local n maxv step + n="$(printf '%s' "${data}" | jq '.labels | length')" + maxv="$(printf '%s' "${data}" | jq '[.series[].values[]] | max // 0')" + maxv="$(nice_max "${maxv}")" + step=$(awk -v n="$n" 'BEGIN{ if (n <= 12) print 1; else print int((n + 11) / 12) }') + + { + printf '\n' \ + "$W" "$H" "$W" "$H" "$(svg_escape "${title}")" + printf '\n' "$W" "$H" + printf '%s\n' "$ml" "$(svg_escape "${title}")" + + local i gy val + for i in 0 1 2 3 4 5; do + gy=$(awk -v mt="$mt" -v ph="$ph" -v i="$i" 'BEGIN{printf "%.1f", mt + ph - (ph*i/5)}') + val=$(awk -v m="$maxv" -v i="$i" 'BEGIN{printf "%d", m*i/5}') + printf '\n' \ + "$ml" "$gy" $(( ml + pw )) "$gy" + printf '%s\n' \ + $(( ml - 8 )) "$(awk -v g="$gy" 'BEGIN{printf "%.1f", g+4}')" "$val" + done + + printf '\n' \ + "$ml" "$mt" "$ml" $(( mt + ph )) + printf '\n' \ + "$ml" $(( mt + ph )) $(( ml + pw )) $(( mt + ph )) + + local idx=0 lbl xx + while IFS= read -r lbl; do + if [ "$n" -gt 1 ]; then + xx=$(awk -v ml="$ml" -v pw="$pw" -v i="$idx" -v n="$n" 'BEGIN{printf "%.1f", ml + pw*i/(n-1)}') + else + xx=$(awk -v ml="$ml" -v pw="$pw" 'BEGIN{printf "%.1f", ml + pw/2}') + fi + if [ $(( idx % step )) -eq 0 ] || [ "$idx" -eq $(( n - 1 )) ]; then + printf '%s\n' \ + "$xx" $(( mt + ph + 20 )) "$(svg_escape "${lbl}")" + fi + idx=$(( idx + 1 )) + done < <(printf '%s' "${data}" | jq -r '.labels[]') + printf '%s\n' \ + $(( ml + pw/2 )) $(( H - 12 )) "$(svg_escape "${xlabel}")" + + local si=0 sname scolor + while IFS= read -r sname; do + scolor="$(printf '%s' "${data}" | jq -r --argjson i "$si" '.series[$i].color')" + local pts="" + local vi=0 v px py + while IFS= read -r v; do + if [ "$n" -gt 1 ]; then + px=$(awk -v ml="$ml" -v pw="$pw" -v i="$vi" -v n="$n" 'BEGIN{printf "%.1f", ml + pw*i/(n-1)}') + else + px=$(awk -v ml="$ml" -v pw="$pw" 'BEGIN{printf "%.1f", ml + pw/2}') + fi + py=$(awk -v mt="$mt" -v ph="$ph" -v val="$v" -v m="$maxv" 'BEGIN{printf "%.1f", mt + ph - (ph*val/m)}') + pts="${pts} ${px},${py}" + vi=$(( vi + 1 )) + done < <(printf '%s' "${data}" | jq -r --argjson i "$si" '.series[$i].values[]') + + printf '\n' "${pts# }" "${scolor}" + for p in ${pts}; do + printf '\n' "${p%,*}" "${p#*,}" "${scolor}" + done + + local ly=$(( mt + si*22 )) + printf '\n' \ + $(( ml + pw + 16 )) "$ly" "${scolor}" + printf '%s\n' \ + $(( ml + pw + 36 )) $(( ly + 12 )) "$(svg_escape "${sname}")" + + si=$(( si + 1 )) + done < <(printf '%s' "${data}" | jq -r '.series[].name') + + printf '\n' + } > "${out}" + echo "wrote ${out}" +} + +# --- History: seed from sparkline if empty, then upsert today ------------- +HISTORY_FILE="${OUT_DIR}/package-downloads-history.jsonl" +snapshot_date="${SNAPSHOT_DATE:-$(date -u +%Y-%m-%d)}" + +touch "${HISTORY_FILE}" +history_len="$(jq -s 'length' "${HISTORY_FILE}" 2>/dev/null || echo 0)" + +# Backfill when history has fewer than 2 rows so monthly deltas are possible +# immediately from the page sparkline. +if [ "${history_len}" -lt 2 ]; then + echo "seeding history from sparkline (history_len=${history_len})" >&2 + spark_seed="$(printf '%s' "${parsed}" | jq -c --arg d "${snapshot_date}" --argjson total "${total}" --argjson last30 "${last30}" ' + .sparkline as $spark + | ($spark | map(.count) | add // 0) as $sum + | ([$total - $sum, 0] | max) as $start + | reduce $spark[] as $day ( + { running: $start, rows: [] }; + .running += $day.count + | .rows += [{ date: $day.date, total: .running, last30Days: null }] + ) + | .rows as $rows + | if ($rows | map(.date) | index($d)) then $rows + else $rows + [{ date: $d, total: $total, last30Days: $last30 }] + end + | map(if .date == $d then .last30Days = $last30 else . end) + ')" + printf '%s' "${spark_seed}" | jq -c '.[]' > "${HISTORY_FILE}.tmp" + mv "${HISTORY_FILE}.tmp" "${HISTORY_FILE}" +fi + +today_row="$(jq -nc --arg d "${snapshot_date}" --argjson total "${total}" --argjson last30 "${last30}" \ + '{date: $d, total: $total, last30Days: $last30}')" + +{ + jq -c --arg d "${snapshot_date}" 'select(.date != $d)' "${HISTORY_FILE}" 2>/dev/null || true + printf '%s\n' "${today_row}" +} | jq -s -c 'sort_by(.date) | .[]' > "${HISTORY_FILE}.tmp" +mv "${HISTORY_FILE}.tmp" "${HISTORY_FILE}" +echo "updated ${HISTORY_FILE} (snapshot ${snapshot_date})" + +# --- Monthly chart from history deltas ------------------------------------ +monthly_rows="$(jq -s -f "${script_dir}/package-stats-monthly.jq" "${HISTORY_FILE}")" + +by_month_data="$(printf '%s' "${monthly_rows}" | jq ' + .[-6:] as $months + | { labels: [ $months[].month ], + series: [ { name: "Downloads", + color: "#2563eb", + values: [ $months[].downloads ] } ] + } +')" +render_line_chart "${OUT_DIR}/package-downloads-monthly.svg" \ + "Monthly DPM package downloads (last 6 months)" "Month (oldest -> newest)" "${by_month_data}" + +# --- Markdown summary ----------------------------------------------------- +{ + echo "" + echo "" + echo + printf '**Total downloads:** %s (GHCR package `canton-devkit`).\n\n' "${total}" + printf '**Last 30 days (page sparkline):** %s.\n\n' "${last30}" + echo "Source: [GitHub Packages](${PACKAGE_URL})." + echo + echo "### Monthly downloads" + echo + echo "| Month | Downloads |" + echo "|---|---|" + if [ "$(printf '%s' "${monthly_rows}" | jq 'length')" -eq 0 ]; then + echo "| — | 0 (need at least two daily snapshots) |" + else + printf '%s' "${monthly_rows}" | jq -r 'reverse[] | "| \(.month) | \(.downloads) |"' + fi + echo +} > "${OUT_DIR}/package-downloads.md" +echo "wrote ${OUT_DIR}/package-downloads.md" diff --git a/scripts/package-stats_test.sh b/scripts/package-stats_test.sh new file mode 100755 index 00000000..4032f229 --- /dev/null +++ b/scripts/package-stats_test.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Regression checks for scripts/package-stats.sh and package-stats-monthly.jq. +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +jq_filter="${script_dir}/package-stats-monthly.jq" +fixture="${script_dir}/testdata/package-stats-page.html" +tmp="$(mktemp -d)" +trap 'rm -rf "${tmp}"' EXIT + +assert_eq() { + local name="$1" got="$2" exp="$3" + got="$(printf '%s' "${got}" | jq -c .)" + exp="$(printf '%s' "${exp}" | jq -c .)" + if [ "${got}" != "${exp}" ]; then + echo "FAIL: ${name}" >&2 + echo " got: ${got}" >&2 + echo " expected: ${exp}" >&2 + exit 1 + fi +} + +assert_file_contains() { + local name="$1" file="$2" needle="$3" + if ! grep -Fq "${needle}" "${file}"; then + echo "FAIL: ${name} — ${file} missing: ${needle}" >&2 + exit 1 + fi +} + +# --- Monthly jq (same contract as release-stats-monthly.jq) ---------------- +cat > "${tmp}/history.jsonl" <<'EOF' +{"date":"2026-07-06","total":10} +{"date":"2026-07-20","total":12} +{"date":"2026-07-28","total":15} +{"date":"2026-08-05","total":18} +EOF +assert_eq "monthly aggregation" \ + "$(jq -s -f "${jq_filter}" "${tmp}/history.jsonl")" \ + '[{"month":"2026-07","downloads":5},{"month":"2026-08","downloads":3}]' + +cat > "${tmp}/history-gap.jsonl" <<'EOF' +{"date":"2026-07-06","total":10} +{"date":"2026-07-10","total":14} +{"date":"2026-09-02","total":20} +EOF +assert_eq "gap fill" \ + "$(jq -s -f "${jq_filter}" "${tmp}/history-gap.jsonl")" \ + '[{"month":"2026-07","downloads":4},{"month":"2026-08","downloads":0},{"month":"2026-09","downloads":6}]' + +cat > "${tmp}/history-clamp.jsonl" <<'EOF' +{"date":"2026-08-03","total":20} +{"date":"2026-08-04","total":17} +{"date":"2026-08-05","total":19} +EOF +assert_eq "negative delta clamp" \ + "$(jq -s -f "${jq_filter}" "${tmp}/history-clamp.jsonl")" \ + '[{"month":"2026-08","downloads":2}]' + +printf '%s\n' '{"date":"2026-08-03","total":20}' > "${tmp}/history-one.jsonl" +assert_eq "single snapshot" \ + "$(jq -s -f "${jq_filter}" "${tmp}/history-one.jsonl")" \ + '[]' + +# --- End-to-end against HTML fixture -------------------------------------- +out="${tmp}/out" +mkdir -p "${out}" +OUT_DIR="${out}" \ + PACKAGE_HTML_FILE="${fixture}" \ + SNAPSHOT_DATE=2026-10-20 \ + bash "${script_dir}/package-stats.sh" >/dev/null + +history="${out}/package-downloads-history.jsonl" +md="${out}/package-downloads.md" +svg="${out}/package-downloads-monthly.svg" + +[ -f "${history}" ] || { echo "FAIL: missing history" >&2; exit 1; } +[ -f "${md}" ] || { echo "FAIL: missing markdown" >&2; exit 1; } +[ -f "${svg}" ] || { echo "FAIL: missing svg" >&2; exit 1; } + +# Fixture total=100; sparkline sums to 100 → first sparkline day starts at 0+10. +assert_eq "history length after seed" \ + "$(jq -s 'length' "${history}")" \ + '6' + +assert_eq "today row" \ + "$(jq -c 'select(.date=="2026-10-20")' "${history}")" \ + '{"date":"2026-10-20","total":100,"last30Days":100}' + +assert_eq "first seeded row" \ + "$(jq -c 'select(.date=="2026-08-01")' "${history}")" \ + '{"date":"2026-08-01","total":10,"last30Days":null}' + +assert_file_contains "md total" "${md}" "**Total downloads:** 100" +assert_file_contains "md month" "${md}" "| 2026-10 | 50 |" +assert_file_contains "svg title" "${svg}" "Monthly DPM package downloads" + +# Idempotent same-day re-run must not duplicate rows. +OUT_DIR="${out}" \ + PACKAGE_HTML_FILE="${fixture}" \ + SNAPSHOT_DATE=2026-10-20 \ + bash "${script_dir}/package-stats.sh" >/dev/null +assert_eq "idempotent history length" \ + "$(jq -s 'length' "${history}")" \ + '6' + +# With existing history (>=2 rows), a new day only upserts today — no re-seed. +OUT_DIR="${out}" \ + PACKAGE_HTML_FILE="${fixture}" \ + SNAPSHOT_DATE=2026-10-21 \ + bash "${script_dir}/package-stats.sh" >/dev/null +# Fixture still reports total=100 on 10-21; length grows by one. +assert_eq "next-day history length" \ + "$(jq -s 'length' "${history}")" \ + '7' +assert_eq "next-day row" \ + "$(jq -c 'select(.date=="2026-10-21")' "${history}")" \ + '{"date":"2026-10-21","total":100,"last30Days":100}' + +bash -n "${script_dir}/package-stats.sh" + +echo "OK: package-stats" diff --git a/scripts/testdata/package-stats-page.html b/scripts/testdata/package-stats-page.html new file mode 100644 index 00000000..5876be51 --- /dev/null +++ b/scripts/testdata/package-stats-page.html @@ -0,0 +1,16 @@ + + +Total downloads +

100

+ +
+ + + + + + + + +
+