diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..6afb211 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,83 @@ +# Docker Hub へイメージを公開する。 +# +# ここまで配布経路は `git clone` だけで、GitHub が数える「ダウンロード」は +# 構造的にゼロだった(自動生成の Source code zip は集計対象外)。Docker Hub は +# pull 数を認証不要の公開 API で返すので、これが最初の本物の DL 指標になる。 +# +# 必要な準備(GitHub → Settings → Secrets and variables → Actions): +# DOCKERHUB_USERNAME Docker Hub のユーザー名(イメージ名にも使う) +# DOCKERHUB_TOKEN Docker Hub の Access Token(パスワードではなく) +# +# 動くタイミング: `v*` タグを push した時と、手動実行。main への push では +# 動かさない(誰も使っていない中間ビルドで pull 数を汚さないため)。 +name: Publish image + +on: + push: + tags: ['v*'] + # Dockerfile まわりを触る PR では、push せずビルドだけ通るか見る。 + # タグを打った当日に初めて壊れているのが分かる、を避けるため。 + pull_request: + paths: + - 'Dockerfile' + - 'requirements.txt' + - '.github/workflows/docker-publish.yml' + workflow_dispatch: + inputs: + tag: + description: '手動公開するときのタグ名(例 v1.3.0)' + required: true + +permissions: + contents: read + +jobs: + # PR ではビルドが通ることだけ確認する(認証情報も要らない)。 + check: + name: image builds + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/build-push-action@v6 + with: + context: . + push: false + cache-from: type=gha + cache-to: type=gha,mode=max + + publish: + name: build and push + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Apple Silicon と x86 の両方で動くように2アーキテクチャ出す。 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Work out the tags + id: meta + run: | + version="${{ github.event.inputs.tag || github.ref_name }}" + image="${{ secrets.DOCKERHUB_USERNAME }}/cli2ui" + echo "tags=${image}:${version#v},${image}:latest" >> "$GITHUB_OUTPUT" + echo "publishing ${image}:${version#v}" + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/metrics.yml b/.github/workflows/metrics.yml new file mode 100644 index 0000000..344e773 --- /dev/null +++ b/.github/workflows/metrics.yml @@ -0,0 +1,40 @@ +# 配布まわりの数字を毎日1回、非公開 Gist に貯める。 +# +# GitHub の traffic API は **直近14日しか返さない**。取り逃がした日は永久に +# 戻らないので、毎日拾って積む。Docker Hub の pull 数とリリースアセットの +# download_count も同時に記録する(こちらは累計値なので日次のスナップショット)。 +# +# 必要な準備: +# METRICS_TOKEN classic PAT。scope は `gist`(Gist 書き込み)と +# `public_repo`(traffic API は push 権限を要求する)。 +# GIST_ID 非公開 Gist の ID(URL 末尾の英数字)。中に +# `cli2ui-metrics.json` を1つ置いておけばよい(空でも可)。 +# +# 数字を見るときは手元で: GIST_ID=... python3 scripts/metrics.py report +name: Metrics + +on: + schedule: + # 12:00 JST(= 03:00 UTC)。traffic は日単位なので時刻はいつでもよいが、 + # 当日の値は日中に増えるため、同じ日を翌日また上書きして取り切る。 + - cron: '0 3 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + snapshot: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Collect and store + env: + METRICS_TOKEN: ${{ secrets.METRICS_TOKEN }} + GIST_ID: ${{ secrets.GIST_ID }} + DOCKERHUB_NAMESPACE: ${{ secrets.DOCKERHUB_USERNAME }} + run: python3 scripts/metrics.py snapshot diff --git a/scripts/metrics.py b/scripts/metrics.py new file mode 100755 index 0000000..4bfeba3 --- /dev/null +++ b/scripts/metrics.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""配布まわりの数字を1コマンドで出す。 + + python3 scripts/metrics.py report # 今の数字を表示する + python3 scripts/metrics.py snapshot # 非公開 Gist に今日の分を追記(CI 用) + +数えているもの: + + * **Docker Hub の pull 数** — 公開 API・認証不要。`git clone` しか配布経路が + 無かった間、GitHub が数える「ダウンロード」は構造的にゼロだった。イメージを + 出して初めて本物の DL 数が立ち上がる。 + * **リリースアセットの download_count** — アセットを添付したリリースのみ。 + 自動生成の Source code (zip/tar.gz) は GitHub が数えない。 + * **traffic の clone / view** — GitHub は**直近14日しか持たない**。毎日 + Gist に落とし込むのはそのため。取り逃がした日は永久に戻らない。 + +環境変数: + METRICS_TOKEN / GITHUB_TOKEN GitHub API 用(未設定なら `gh auth token`) + GIST_ID snapshot の保存先(非公開 Gist の ID) + DOCKERHUB_NAMESPACE Docker Hub のユーザー名(既定 'jiniie') +""" + +import argparse +import json +import os +import subprocess +import sys +import urllib.error +import urllib.request +from datetime import date, datetime, timezone + +REPO = os.getenv('METRICS_REPO', 'MR-TABATA/cli2ui') +DOCKER_NAMESPACE = os.getenv('DOCKERHUB_NAMESPACE', 'jiniie') +DOCKER_IMAGE = os.getenv('DOCKERHUB_IMAGE', 'cli2ui') +GIST_FILENAME = 'cli2ui-metrics.json' + + +# --------------------------------------------------------------------------- +# HTTP +# --------------------------------------------------------------------------- + +def _token() -> str: + for var in ('METRICS_TOKEN', 'GITHUB_TOKEN', 'GH_TOKEN'): + if os.getenv(var): + return os.environ[var] + try: + return subprocess.check_output(['gh', 'auth', 'token'], text=True).strip() + except (OSError, subprocess.CalledProcessError): + sys.exit('GitHub のトークンが無い。METRICS_TOKEN を設定するか `gh auth login` を実行する。') + + +def _get(url: str, token: str | None = None, method: str = 'GET', body=None): + """JSON を返す。404 は None(まだ公開していないイメージ等)。""" + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header('Accept', 'application/vnd.github+json') + if token: + req.add_header('Authorization', f'Bearer {token}') + if data: + req.add_header('Content-Type', 'application/json') + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read() or b'null') + except urllib.error.HTTPError as e: + if e.code == 404: + return None + raise + + +# --------------------------------------------------------------------------- +# 収集 +# --------------------------------------------------------------------------- + +def docker_pulls() -> dict | None: + """Docker Hub の pull 数。未公開/名前未設定なら None。""" + if not DOCKER_NAMESPACE: + return None + url = f'https://hub.docker.com/v2/repositories/{DOCKER_NAMESPACE}/{DOCKER_IMAGE}/' + info = _get(url) + if not info: + return None + return {'pulls': info.get('pull_count', 0), + 'stars': info.get('star_count', 0), + 'last_updated': (info.get('last_updated') or '')[:10]} + + +def release_downloads(token: str) -> list: + releases = _get(f'https://api.github.com/repos/{REPO}/releases', token) or [] + out = [] + for rel in releases: + assets = [{'name': a['name'], 'downloads': a['download_count']} + for a in rel.get('assets', [])] + out.append({'tag': rel['tag_name'], + 'published': (rel.get('published_at') or '')[:10], + 'assets': assets, + 'downloads': sum(a['downloads'] for a in assets)}) + return out + + +def traffic(token: str) -> dict: + """直近14日の clone / view。push 権限が要る。""" + clones = _get(f'https://api.github.com/repos/{REPO}/traffic/clones', token) or {} + views = _get(f'https://api.github.com/repos/{REPO}/traffic/views', token) or {} + days: dict[str, dict] = {} + for row in clones.get('clones', []): + days.setdefault(row['timestamp'][:10], {}).update( + clones=row['count'], clone_uniques=row['uniques']) + for row in views.get('views', []): + days.setdefault(row['timestamp'][:10], {}).update( + views=row['count'], view_uniques=row['uniques']) + return {'days': days, + 'clones_14d': clones.get('count', 0), + 'clone_uniques_14d': clones.get('uniques', 0), + 'views_14d': views.get('count', 0), + 'view_uniques_14d': views.get('uniques', 0)} + + +# --------------------------------------------------------------------------- +# Gist(履歴の置き場) +# --------------------------------------------------------------------------- + +def _gist_load(gist_id: str, token: str) -> dict: + gist = _get(f'https://api.github.com/gists/{gist_id}', token) + if not gist: + sys.exit(f'Gist {gist_id} が見つからない。GIST_ID とトークンの scope を確認する。') + files = gist.get('files') or {} + blob = files.get(GIST_FILENAME) + if not blob: + return {'repo': REPO, 'days': {}, 'totals': {}} + if blob.get('truncated'): + content = urllib.request.urlopen(blob['raw_url'], timeout=30).read().decode() + else: + content = blob['content'] + return json.loads(content or '{}') or {'repo': REPO, 'days': {}, 'totals': {}} + + +def _gist_save(gist_id: str, token: str, payload: dict) -> None: + body = {'files': {GIST_FILENAME: {'content': json.dumps(payload, indent=2, + ensure_ascii=False)}}} + _get(f'https://api.github.com/gists/{gist_id}', token, method='PATCH', body=body) + + +def snapshot() -> None: + """今日ぶんを取り込んで Gist を更新する。同じ日は新しい値で上書き。""" + gist_id = os.getenv('GIST_ID') + if not gist_id: + sys.exit('GIST_ID が未設定。') + token = _token() + + store = _gist_load(gist_id, token) + store.setdefault('repo', REPO) + store.setdefault('days', {}) + store.setdefault('totals', {}) + + t = traffic(token) + # 当日の値は日中に増える。取り直したら常に新しい方で置き換える。 + for day, row in t['days'].items(): + store['days'][day] = {**store['days'].get(day, {}), **row} + + today = date.today().isoformat() + pulls = docker_pulls() + releases = release_downloads(token) + store['totals'][today] = { + 'docker_pulls': (pulls or {}).get('pulls'), + 'release_downloads': sum(r['downloads'] for r in releases), + 'releases': {r['tag']: r['downloads'] for r in releases}, + } + store['updated_at'] = datetime.now(timezone.utc).isoformat(timespec='seconds') + _gist_save(gist_id, token, store) + + print(f"snapshot ok — {len(store['days'])} 日分, 最新 {today}") + print(f" clone(14d): {t['clones_14d']} / unique {t['clone_uniques_14d']}") + print(f" docker pulls: {(pulls or {}).get('pulls', '未公開')}") + + +# --------------------------------------------------------------------------- +# 表示 +# --------------------------------------------------------------------------- + +def report() -> None: + token = _token() + print(f'== {REPO} ==\n') + + pulls = docker_pulls() + print('-- Docker Hub --') + if pulls: + print(f" pull 数 : {pulls['pulls']}") + print(f" star : {pulls['stars']}") + print(f" 最終更新 : {pulls['last_updated']}") + elif DOCKER_NAMESPACE: + print(f' {DOCKER_NAMESPACE}/{DOCKER_IMAGE} はまだ Docker Hub に無い' + '(v* タグを push すると公開される)') + else: + print(' DOCKERHUB_NAMESPACE 未設定 — Docker Hub のユーザー名を入れると pull 数を出す') + + print('\n-- リリースアセット --') + releases = release_downloads(token) + if not releases: + print(' リリースなし') + for rel in releases: + if rel['assets']: + print(f" {rel['tag']} ({rel['published']}): {rel['downloads']} DL") + for a in rel['assets']: + print(f" {a['name']}: {a['downloads']}") + else: + print(f" {rel['tag']} ({rel['published']}): アセット無し " + f"— 自動生成の Source code は GitHub が数えない") + + print('\n-- GitHub traffic(直近14日・ここだけ消える)--') + t = traffic(token) + print(f" clone : {t['clones_14d']} (unique {t['clone_uniques_14d']})") + print(f" view : {t['views_14d']} (unique {t['view_uniques_14d']})") + + gist_id = os.getenv('GIST_ID') + if gist_id: + store = _gist_load(gist_id, token) + days = store.get('days', {}) + if days: + first, last = min(days), max(days) + tot_c = sum(d.get('clones', 0) for d in days.values()) + tot_u = sum(d.get('clone_uniques', 0) for d in days.values()) + print(f"\n-- 蓄積した履歴(Gist)--") + print(f" 期間 : {first} 〜 {last}({len(days)} 日)") + print(f" clone累計: {tot_c}(日ごと unique の合計 {tot_u})") + print(" ※ unique は日単位。別の日に来た同じ人は二重に数える。") + else: + print('\n(GIST_ID を設定すると、蓄積した履歴も併せて表示する)') + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('command', choices=('report', 'snapshot')) + args = ap.parse_args() + (report if args.command == 'report' else snapshot)() + + +if __name__ == '__main__': + main()