diff --git a/.github/workflows/Create-NewReleases.yml b/.github/workflows/Create-NewReleases.yml index 5059e092..c48574ec 100644 --- a/.github/workflows/Create-NewReleases.yml +++ b/.github/workflows/Create-NewReleases.yml @@ -79,6 +79,17 @@ jobs: - name: Push Changes run: git push origin main + - name: Build and Validate Counted Release Assets + run: | + cp MerlinAU.sh MerlinAU-install.sh + cp MerlinAU.sh MerlinAU-update.sh + + sed -i 's/^readonly RELEASE_ASSET_KIND=.*/readonly RELEASE_ASSET_KIND="install"/' MerlinAU-install.sh + sed -i 's/^readonly RELEASE_ASSET_KIND=.*/readonly RELEASE_ASSET_KIND="update"/' MerlinAU-update.sh + + grep -Fxq 'readonly RELEASE_ASSET_KIND="install"' MerlinAU-install.sh + grep -Fxq 'readonly RELEASE_ASSET_KIND="update"' MerlinAU-update.sh + - name: Create and Push Tag run: | git tag ${{ steps.nextver.outputs.tag }} @@ -92,3 +103,6 @@ jobs: name: "Release ${{ steps.nextver.outputs.tag }}" prerelease: false generate_release_notes: true + files: | + MerlinAU-install.sh + MerlinAU-update.sh diff --git a/.github/workflows/Track_Metrics_MerlinAU.yml b/.github/workflows/Track_Metrics_MerlinAU.yml new file mode 100644 index 00000000..52fca3d9 --- /dev/null +++ b/.github/workflows/Track_Metrics_MerlinAU.yml @@ -0,0 +1,64 @@ +name: Track Release Downloads + +on: + schedule: + - cron: '0 0 * * *' + workflow_dispatch: + +jobs: + snapshot-metrics: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v7.0.1 + with: + fetch-depth: 0 + + - name: Restore metrics history + shell: bash + run: | + mkdir -p metrics + + if git ls-remote --exit-code --heads origin metrics >/dev/null 2>&1; then + git fetch origin metrics:refs/remotes/origin/metrics + git show origin/metrics:metrics/release_downloads.csv \ + > metrics/release_downloads.csv 2>/dev/null || true + fi + + - name: Snapshot release download counts + env: + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python3 track_downloads.py + + - name: Publish metrics history + shell: bash + run: | + cp metrics/release_downloads.csv /tmp/release_downloads.csv + rm -rf metrics + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + if git show-ref --verify --quiet refs/remotes/origin/metrics; then + git switch --force-create metrics origin/metrics + else + git switch --orphan metrics + git rm -rf . >/dev/null 2>&1 || true + fi + + mkdir -p metrics + cp /tmp/release_downloads.csv metrics/release_downloads.csv + + git add metrics/release_downloads.csv + + if git diff --cached --quiet; then + echo "No metrics changes to commit." + exit 0 + fi + + git commit -m "Update release download metrics" + git push origin metrics diff --git a/MerlinAU.sh b/MerlinAU.sh index 3e3a059b..bc7358e8 100644 --- a/MerlinAU.sh +++ b/MerlinAU.sh @@ -19,11 +19,11 @@ set -u ## Set version for each Production Release ## -readonly SCRIPT_VERSION=1.6.9 -readonly SCRIPT_VERSTAG="26092509" +readonly SCRIPT_VERSION=1.7.0 +readonly SCRIPT_VERSTAG="26092816" readonly SCRIPT_NAME="MerlinAU" ## Set to "master" for Production Releases ## -SCRIPT_BRANCH="master" +SCRIPT_BRANCH="dev" ##----------------------------------------## ## Modified by Martinski W. [2024-Jul-03] ## @@ -32,6 +32,15 @@ SCRIPT_BRANCH="master" readonly SCRIPT_URL_BASE="https://raw.githubusercontent.com/ExtremeFiretop/MerlinAutoUpdate-Router" SCRIPT_URL_REPO="${SCRIPT_URL_BASE}/$SCRIPT_BRANCH" +# Production release assets are used for install/update downloads so GitHub's +# native per-asset download counters can provide aggregate usage statistics. +# Development builds continue to use raw.githubusercontent.com. +readonly RELEASE_URL_BASE="https://github.com/ExtremeFiretop/MerlinAutoUpdate-Router/releases/download" +readonly RELEASE_INSTALL_ASSET="${SCRIPT_NAME}-install.sh" +readonly RELEASE_UPDATE_ASSET="${SCRIPT_NAME}-update.sh" +# Rewritten to "install" or "update" in generated GitHub Release assets. +readonly RELEASE_ASSET_KIND="source" + # Firmware URL Info # readonly FW_SFURL_BASE="https://sourceforge.net/projects/asuswrt-merlin/files" readonly FW_SFURL_RELEASE_SUFFIX="Release" @@ -2947,9 +2956,14 @@ _CurlFileDownload_() then return 1 fi local tempFilePathDL="${2}.DL.$$.TMP" - local srceFilePathURL="${SCRIPT_URL_REPO}/$1" + local srceFilePathURL local curlRetCode returnCODE statusSTRx httpStatusSTR + if [ $# -gt 2 ] && [ -n "$3" ] + then srceFilePathURL="$3" + else srceFilePathURL="${SCRIPT_URL_REPO}/$1" + fi + rm -f "$tempFilePathDL" printf '' > "$curlErrLogFPath" printf '' > "$curlTmpLogFPath" @@ -2995,9 +3009,60 @@ _CurlFileDownload_() return "$returnCODE" } -##----------------------------------------## -## Modified by maghuro [2026-Sep-24] ## -##----------------------------------------## +##------------------------------------------## +## Added by ExtremeFiretop [2026-Sep-28] ## +##------------------------------------------## +# Download a production script from a version-pinned GitHub Release asset. +# Validate the downloaded script's asset type before replacing the installed script. +# This provides aggregate GitHub# download counts without adding a third-party redirect service +# or a persistent client identifier. +_DownloadReleaseScriptAsset_() +{ + if [ $# -lt 3 ] || [ -z "$1" ] || [ -z "$2" ] || [ -z "$3" ] + then return 1 + fi + + local assetKind="$1" releaseVersion="$2" destFilePath="$3" + local assetName releaseBaseURL tempScriptPath + local downloadedKind + + case "$assetKind" in + install) assetName="$RELEASE_INSTALL_ASSET" ;; + update) assetName="$RELEASE_UPDATE_ASSET" ;; + *) return 1 ;; + esac + + releaseBaseURL="${RELEASE_URL_BASE}/${releaseVersion}" + tempScriptPath="${destFilePath}.REL.$$.TMP" + rm -f "$tempScriptPath" + + if ! _CurlFileDownload_ "$assetName" "$tempScriptPath" \ + "${releaseBaseURL}/${assetName}" + then + rm -f "$tempScriptPath" + return 1 + fi + + downloadedKind="$(grep -m1 '^readonly RELEASE_ASSET_KIND=' "$tempScriptPath" | cut -d'"' -f2)" + + if [ "$downloadedKind" != "$assetKind" ] + then + Say "${REDct}**ERROR**${NOct}: Release asset type validation failed for [$assetName]." + rm -f "$tempScriptPath" + return 1 + fi + + if ! mv -f "$tempScriptPath" "$destFilePath" + then + rm -f "$tempScriptPath" + return 1 + fi + return 0 +} + +##------------------------------------------## +## Modified by ExtremeFiretop [2026-Sep-28] ## +##------------------------------------------## _DownloadScriptFiles_() { local retCode=0 isUpdateAction updatedWebUIPage theWebPage @@ -3038,7 +3103,45 @@ _DownloadScriptFiles_() Say "${REDct}**ERROR**${NOct}: Unable to download latest WebUI ASP file for $SCRIPT_NAME." fi - if _CurlFileDownload_ "${SCRIPT_NAME}.sh" "$ScriptFilePath" + local scriptDownloadOK=false releaseVersion + + # Stable production installs/updates use GitHub Release assets so GitHub + # can count aggregate install/update downloads. Development stays on raw + # GitHub and is intentionally excluded from production download counts. + if [ "$SCRIPT_BRANCH" = "master" ] + then + if "$isUpdateAction" + then + releaseVersion="$DLRepoVersion" + [ -z "$releaseVersion" ] && releaseVersion="$(head -n1 "$SCRIPT_VERPATH")" + if _DownloadReleaseScriptAsset_ update "$releaseVersion" "$ScriptFilePath" + then scriptDownloadOK=true + fi + elif [ "$RELEASE_ASSET_KIND" = "install" ] + then + # The bootstrap itself was the counted install asset. Do not fetch + # the same asset a second time and inflate the install counter. + scriptDownloadOK=true + else + releaseVersion="$(head -n1 "$SCRIPT_VERPATH")" + if _DownloadReleaseScriptAsset_ install "$releaseVersion" "$ScriptFilePath" + then + scriptDownloadOK=true + else + # Preserve compatibility with a short release-publication race + # or an older AMTM bootstrap by falling back to the raw script. + Say "${YLWct}*WARNING*${NOct}: Counted install asset unavailable; falling back to repository source." + if _CurlFileDownload_ "${SCRIPT_NAME}.sh" "$ScriptFilePath" + then scriptDownloadOK=true + fi + fi + fi + elif _CurlFileDownload_ "${SCRIPT_NAME}.sh" "$ScriptFilePath" + then + scriptDownloadOK=true + fi + + if "$scriptDownloadOK" then dos2unix "$ScriptFilePath" chmod 755 "$ScriptFilePath" diff --git a/README.md b/README.md index 3f17e158..b0ab2416 100644 --- a/README.md +++ b/README.md @@ -194,9 +194,13 @@ Use your preferred SSH client to connect to the router. *Manual Installation* 1. To Download the script to your router, Copy and paste: ```bash -curl --retry 3 "https://raw.githubusercontent.com/ExtremeFiretop/MerlinAutoUpdate-Router/master/MerlinAU.sh" -o "/jffs/scripts/MerlinAU.sh" && chmod +x "/jffs/scripts/MerlinAU.sh" && sh /jffs/scripts/MerlinAU.sh install +curl -fL --retry 3 "https://github.com/ExtremeFiretop/MerlinAutoUpdate-Router/releases/latest/download/MerlinAU-install.sh" -o "/jffs/scripts/MerlinAU.sh" && chmod +x "/jffs/scripts/MerlinAU.sh" && sh /jffs/scripts/MerlinAU.sh install ``` - The script is now ready for use! + +### Aggregate Download Counts and Privacy +Stable MerlinAU releases use separate GitHub Release assets for fresh installs and script updates. This way, GitHub's native download counter can provide aggregate install/update activity without adding a third-party gateway, cookies, installation ID, or telemetry. +The counters represent **download events, not unique users or routers**. Reinstalls, forced updates, retries, or manual downloads can increment them more than once. ## Usage diff --git a/track_downloads.py b/track_downloads.py new file mode 100644 index 00000000..c4dedcb9 --- /dev/null +++ b/track_downloads.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import csv +import json +import os +import sys +from datetime import datetime, timezone +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +ASSETS = { + "MerlinAU-install.sh": "install", + "MerlinAU-update.sh": "update", +} + +OUTPUT_PATH = Path("metrics/release_downloads.csv") + +FIELDNAMES = [ + "snapshot_date", + "snapshot_time_utc", + "tag", + "published_at", + "kind", + "asset_name", + "asset_id", + "download_count", +] + + +def github_get(url: str, token: str | None): + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "MerlinAU-release-metrics", + } + if token: + headers["Authorization"] = f"Bearer {token}" + + request = Request(url, headers=headers) + + try: + with urlopen(request, timeout=30) as response: + return json.load(response) + except HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + raise RuntimeError( + f"GitHub API request failed: HTTP {exc.code} for {url}\n{body}" + ) from exc + except URLError as exc: + raise RuntimeError(f"GitHub API request failed for {url}: {exc}") from exc + + +def get_releases(repository: str, token: str | None) -> list[dict]: + releases = [] + page = 1 + + while True: + url = ( + f"https://api.github.com/repos/{repository}/releases" + f"?per_page=100&page={page}" + ) + payload = github_get(url, token) + + if not isinstance(payload, list): + raise RuntimeError("Unexpected GitHub API response while listing releases.") + + releases.extend(payload) + + if len(payload) < 100: + break + + page += 1 + + return releases + + +def load_existing_rows(path: Path): + rows = {} + + if not path.exists(): + return rows + + with path.open("r", newline="", encoding="utf-8") as handle: + reader = csv.DictReader(handle) + + missing = [field for field in FIELDNAMES if field not in (reader.fieldnames or [])] + if missing: + raise RuntimeError( + f"{path} is missing expected columns: {', '.join(missing)}" + ) + + for row in reader: + key = (row["snapshot_date"], row["tag"], row["asset_name"]) + rows[key] = {field: row.get(field, "") for field in FIELDNAMES} + + return rows + + +def write_rows(path: Path, rows) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temp_path = path.with_suffix(path.suffix + ".tmp") + + ordered_rows = sorted( + rows.values(), + key=lambda row: ( + row["snapshot_date"], + row["published_at"], + row["tag"], + row["kind"], + row["asset_name"], + ), + ) + + with temp_path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=FIELDNAMES) + writer.writeheader() + writer.writerows(ordered_rows) + + temp_path.replace(path) + + +def main() -> int: + repository = os.environ.get("GITHUB_REPOSITORY", "").strip() + token = os.environ.get("GITHUB_TOKEN", "").strip() or None + + if not repository or "/" not in repository: + print("ERROR: GITHUB_REPOSITORY must be set to owner/repository.", file=sys.stderr) + return 1 + + now = datetime.now(timezone.utc).replace(microsecond=0) + snapshot_date = now.date().isoformat() + snapshot_time = now.isoformat().replace("+00:00", "Z") + + rows = load_existing_rows(OUTPUT_PATH) + releases = get_releases(repository, token) + + matched_assets = 0 + + for release in releases: + if release.get("draft") or release.get("prerelease"): + continue + + tag = str(release.get("tag_name") or "") + published_at = str(release.get("published_at") or "") + + for asset in release.get("assets") or []: + asset_name = str(asset.get("name") or "") + kind = ASSETS.get(asset_name) + + if kind is None: + continue + + row = { + "snapshot_date": snapshot_date, + "snapshot_time_utc": snapshot_time, + "tag": tag, + "published_at": published_at, + "kind": kind, + "asset_name": asset_name, + "asset_id": str(asset.get("id") or ""), + "download_count": str(asset.get("download_count") or 0), + } + + # Same-day manual reruns refresh the row instead of duplicating it. + key = (snapshot_date, tag, asset_name) + rows[key] = row + matched_assets += 1 + + write_rows(OUTPUT_PATH, rows) + + print( + f"Recorded {matched_assets} counted release asset(s) for " + f"{snapshot_date} in {OUTPUT_PATH}." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())