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 b82b5539..3a8655bd 100644
--- a/README.md
+++ b/README.md
@@ -130,12 +130,27 @@ For general Canton and Daml questions, use the
## Download statistics
-(Standalone install downloads only, DPM installs via `dpm install` are not included.)
+### 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).


+### 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).
+
+
+
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for the build, test, and lint
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; 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'
+ } > "${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
+
+
+
+
+