diff --git a/.github/star-history/THIRD_PARTY_NOTICES.md b/.github/star-history/THIRD_PARTY_NOTICES.md
new file mode 100644
index 00000000..54cb54b7
--- /dev/null
+++ b/.github/star-history/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,29 @@
+# Third-party notices
+
+## Star History logo icon
+
+The self-contained Star History SVG renderer includes `logo-icon.png` from
+[star-history/star-history](https://github.com/star-history/star-history), used
+as the `star-history.com` watermark.
+
+MIT License
+
+Copyright (c) 2025 Star History
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/.github/star-history/history.json b/.github/star-history/history.json
new file mode 100644
index 00000000..cb9d4d9b
--- /dev/null
+++ b/.github/star-history/history.json
@@ -0,0 +1,866 @@
+{
+ "ongoing_interval_days": 13,
+ "reconstruction": {
+ "daily": [
+ {
+ "date": "2025-11-28",
+ "stars": 1
+ },
+ {
+ "date": "2025-12-22",
+ "stars": 40
+ },
+ {
+ "date": "2025-12-23",
+ "stars": 270
+ },
+ {
+ "date": "2025-12-24",
+ "stars": 378
+ },
+ {
+ "date": "2025-12-25",
+ "stars": 430
+ },
+ {
+ "date": "2025-12-26",
+ "stars": 477
+ },
+ {
+ "date": "2025-12-27",
+ "stars": 497
+ },
+ {
+ "date": "2025-12-28",
+ "stars": 525
+ },
+ {
+ "date": "2025-12-29",
+ "stars": 577
+ },
+ {
+ "date": "2025-12-30",
+ "stars": 611
+ },
+ {
+ "date": "2025-12-31",
+ "stars": 646
+ },
+ {
+ "date": "2026-01-01",
+ "stars": 683
+ },
+ {
+ "date": "2026-01-02",
+ "stars": 719
+ },
+ {
+ "date": "2026-01-03",
+ "stars": 753
+ },
+ {
+ "date": "2026-01-04",
+ "stars": 798
+ },
+ {
+ "date": "2026-01-05",
+ "stars": 838
+ },
+ {
+ "date": "2026-01-06",
+ "stars": 894
+ },
+ {
+ "date": "2026-01-07",
+ "stars": 955
+ },
+ {
+ "date": "2026-01-08",
+ "stars": 1026
+ },
+ {
+ "date": "2026-01-09",
+ "stars": 1073
+ },
+ {
+ "date": "2026-01-10",
+ "stars": 1085
+ },
+ {
+ "date": "2026-01-11",
+ "stars": 1101
+ },
+ {
+ "date": "2026-01-12",
+ "stars": 1125
+ },
+ {
+ "date": "2026-01-13",
+ "stars": 1455
+ },
+ {
+ "date": "2026-01-14",
+ "stars": 1768
+ },
+ {
+ "date": "2026-01-15",
+ "stars": 1955
+ },
+ {
+ "date": "2026-01-16",
+ "stars": 2060
+ },
+ {
+ "date": "2026-01-17",
+ "stars": 2082
+ },
+ {
+ "date": "2026-01-18",
+ "stars": 2109
+ },
+ {
+ "date": "2026-01-19",
+ "stars": 2162
+ },
+ {
+ "date": "2026-01-20",
+ "stars": 2341
+ },
+ {
+ "date": "2026-01-21",
+ "stars": 2604
+ },
+ {
+ "date": "2026-01-22",
+ "stars": 2735
+ },
+ {
+ "date": "2026-01-23",
+ "stars": 2829
+ },
+ {
+ "date": "2026-01-24",
+ "stars": 2900
+ },
+ {
+ "date": "2026-01-25",
+ "stars": 2958
+ },
+ {
+ "date": "2026-01-26",
+ "stars": 2999
+ },
+ {
+ "date": "2026-01-27",
+ "stars": 3076
+ },
+ {
+ "date": "2026-01-28",
+ "stars": 3114
+ },
+ {
+ "date": "2026-01-29",
+ "stars": 3158
+ },
+ {
+ "date": "2026-01-30",
+ "stars": 3212
+ },
+ {
+ "date": "2026-01-31",
+ "stars": 3234
+ },
+ {
+ "date": "2026-02-01",
+ "stars": 3253
+ },
+ {
+ "date": "2026-02-02",
+ "stars": 3283
+ },
+ {
+ "date": "2026-02-03",
+ "stars": 3327
+ },
+ {
+ "date": "2026-02-04",
+ "stars": 3367
+ },
+ {
+ "date": "2026-02-05",
+ "stars": 3406
+ },
+ {
+ "date": "2026-02-06",
+ "stars": 3435
+ },
+ {
+ "date": "2026-02-07",
+ "stars": 3467
+ },
+ {
+ "date": "2026-02-08",
+ "stars": 3503
+ },
+ {
+ "date": "2026-02-09",
+ "stars": 3527
+ },
+ {
+ "date": "2026-02-10",
+ "stars": 3551
+ },
+ {
+ "date": "2026-02-11",
+ "stars": 3579
+ },
+ {
+ "date": "2026-02-12",
+ "stars": 3606
+ },
+ {
+ "date": "2026-02-13",
+ "stars": 3634
+ },
+ {
+ "date": "2026-02-14",
+ "stars": 3657
+ },
+ {
+ "date": "2026-02-15",
+ "stars": 3673
+ },
+ {
+ "date": "2026-02-16",
+ "stars": 3703
+ },
+ {
+ "date": "2026-02-17",
+ "stars": 3732
+ },
+ {
+ "date": "2026-02-18",
+ "stars": 3752
+ },
+ {
+ "date": "2026-02-19",
+ "stars": 3834
+ },
+ {
+ "date": "2026-02-20",
+ "stars": 3912
+ },
+ {
+ "date": "2026-02-21",
+ "stars": 3931
+ },
+ {
+ "date": "2026-02-22",
+ "stars": 3941
+ },
+ {
+ "date": "2026-02-23",
+ "stars": 3954
+ },
+ {
+ "date": "2026-02-24",
+ "stars": 3973
+ },
+ {
+ "date": "2026-02-25",
+ "stars": 3989
+ },
+ {
+ "date": "2026-02-26",
+ "stars": 4002
+ },
+ {
+ "date": "2026-02-27",
+ "stars": 4020
+ },
+ {
+ "date": "2026-02-28",
+ "stars": 4042
+ },
+ {
+ "date": "2026-03-01",
+ "stars": 4058
+ },
+ {
+ "date": "2026-03-02",
+ "stars": 4085
+ },
+ {
+ "date": "2026-03-03",
+ "stars": 4109
+ },
+ {
+ "date": "2026-03-04",
+ "stars": 4128
+ },
+ {
+ "date": "2026-03-05",
+ "stars": 4248
+ },
+ {
+ "date": "2026-03-06",
+ "stars": 4695
+ },
+ {
+ "date": "2026-03-07",
+ "stars": 5336
+ },
+ {
+ "date": "2026-03-08",
+ "stars": 6575
+ },
+ {
+ "date": "2026-03-09",
+ "stars": 10278
+ },
+ {
+ "date": "2026-03-10",
+ "stars": 13543
+ },
+ {
+ "date": "2026-03-11",
+ "stars": 16122
+ },
+ {
+ "date": "2026-03-12",
+ "stars": 18426
+ },
+ {
+ "date": "2026-03-13",
+ "stars": 21121
+ },
+ {
+ "date": "2026-03-14",
+ "stars": 23302
+ },
+ {
+ "date": "2026-03-15",
+ "stars": 26299
+ },
+ {
+ "date": "2026-03-16",
+ "stars": 29077
+ },
+ {
+ "date": "2026-03-17",
+ "stars": 31779
+ },
+ {
+ "date": "2026-03-18",
+ "stars": 33459
+ },
+ {
+ "date": "2026-03-19",
+ "stars": 34942
+ },
+ {
+ "date": "2026-03-20",
+ "stars": 36279
+ },
+ {
+ "date": "2026-03-21",
+ "stars": 37440
+ },
+ {
+ "date": "2026-03-22",
+ "stars": 38779
+ },
+ {
+ "date": "2026-03-23",
+ "stars": 39941
+ },
+ {
+ "date": "2026-03-24",
+ "stars": 40913
+ },
+ {
+ "date": "2026-03-25",
+ "stars": 41761
+ },
+ {
+ "date": "2026-03-26",
+ "stars": 42672
+ },
+ {
+ "date": "2026-03-27",
+ "stars": 43370
+ },
+ {
+ "date": "2026-03-28",
+ "stars": 43999
+ },
+ {
+ "date": "2026-03-29",
+ "stars": 44630
+ },
+ {
+ "date": "2026-03-30",
+ "stars": 45425
+ },
+ {
+ "date": "2026-03-31",
+ "stars": 46118
+ },
+ {
+ "date": "2026-04-01",
+ "stars": 46740
+ },
+ {
+ "date": "2026-04-02",
+ "stars": 47421
+ },
+ {
+ "date": "2026-04-03",
+ "stars": 48061
+ },
+ {
+ "date": "2026-04-04",
+ "stars": 48517
+ },
+ {
+ "date": "2026-04-05",
+ "stars": 49170
+ },
+ {
+ "date": "2026-04-06",
+ "stars": 49863
+ },
+ {
+ "date": "2026-04-07",
+ "stars": 50461
+ },
+ {
+ "date": "2026-04-08",
+ "stars": 51175
+ },
+ {
+ "date": "2026-04-09",
+ "stars": 51931
+ },
+ {
+ "date": "2026-04-10",
+ "stars": 52444
+ },
+ {
+ "date": "2026-04-11",
+ "stars": 52970
+ },
+ {
+ "date": "2026-04-12",
+ "stars": 53419
+ },
+ {
+ "date": "2026-04-13",
+ "stars": 53936
+ },
+ {
+ "date": "2026-04-14",
+ "stars": 54326
+ },
+ {
+ "date": "2026-04-15",
+ "stars": 54652
+ },
+ {
+ "date": "2026-04-16",
+ "stars": 54949
+ },
+ {
+ "date": "2026-04-17",
+ "stars": 55207
+ },
+ {
+ "date": "2026-04-18",
+ "stars": 55386
+ },
+ {
+ "date": "2026-04-19",
+ "stars": 55574
+ },
+ {
+ "date": "2026-04-20",
+ "stars": 55783
+ },
+ {
+ "date": "2026-04-21",
+ "stars": 55988
+ },
+ {
+ "date": "2026-04-22",
+ "stars": 56203
+ },
+ {
+ "date": "2026-04-23",
+ "stars": 56364
+ },
+ {
+ "date": "2026-04-24",
+ "stars": 56530
+ },
+ {
+ "date": "2026-04-25",
+ "stars": 56689
+ },
+ {
+ "date": "2026-04-26",
+ "stars": 56921
+ },
+ {
+ "date": "2026-04-27",
+ "stars": 57161
+ },
+ {
+ "date": "2026-04-28",
+ "stars": 57400
+ },
+ {
+ "date": "2026-04-29",
+ "stars": 57658
+ },
+ {
+ "date": "2026-04-30",
+ "stars": 57873
+ },
+ {
+ "date": "2026-05-01",
+ "stars": 58048
+ },
+ {
+ "date": "2026-05-02",
+ "stars": 58213
+ },
+ {
+ "date": "2026-05-03",
+ "stars": 58386
+ },
+ {
+ "date": "2026-05-04",
+ "stars": 58522
+ },
+ {
+ "date": "2026-05-05",
+ "stars": 58673
+ },
+ {
+ "date": "2026-05-06",
+ "stars": 58820
+ },
+ {
+ "date": "2026-05-07",
+ "stars": 58977
+ },
+ {
+ "date": "2026-05-08",
+ "stars": 59115
+ },
+ {
+ "date": "2026-05-09",
+ "stars": 59239
+ },
+ {
+ "date": "2026-05-10",
+ "stars": 59417
+ },
+ {
+ "date": "2026-05-11",
+ "stars": 59622
+ },
+ {
+ "date": "2026-05-12",
+ "stars": 59811
+ },
+ {
+ "date": "2026-05-13",
+ "stars": 60008
+ },
+ {
+ "date": "2026-05-14",
+ "stars": 60186
+ },
+ {
+ "date": "2026-05-15",
+ "stars": 60318
+ },
+ {
+ "date": "2026-05-16",
+ "stars": 60431
+ },
+ {
+ "date": "2026-05-17",
+ "stars": 60563
+ },
+ {
+ "date": "2026-05-18",
+ "stars": 60695
+ },
+ {
+ "date": "2026-05-19",
+ "stars": 60807
+ },
+ {
+ "date": "2026-05-20",
+ "stars": 60921
+ },
+ {
+ "date": "2026-05-21",
+ "stars": 61024
+ },
+ {
+ "date": "2026-05-22",
+ "stars": 61118
+ },
+ {
+ "date": "2026-05-23",
+ "stars": 61320
+ },
+ {
+ "date": "2026-05-24",
+ "stars": 61680
+ },
+ {
+ "date": "2026-05-25",
+ "stars": 62078
+ },
+ {
+ "date": "2026-05-26",
+ "stars": 62214
+ },
+ {
+ "date": "2026-05-27",
+ "stars": 62411
+ },
+ {
+ "date": "2026-05-28",
+ "stars": 62613
+ },
+ {
+ "date": "2026-05-29",
+ "stars": 62761
+ },
+ {
+ "date": "2026-05-30",
+ "stars": 62909
+ },
+ {
+ "date": "2026-05-31",
+ "stars": 63036
+ },
+ {
+ "date": "2026-06-01",
+ "stars": 63165
+ },
+ {
+ "date": "2026-06-02",
+ "stars": 63305
+ },
+ {
+ "date": "2026-06-03",
+ "stars": 63519
+ },
+ {
+ "date": "2026-06-04",
+ "stars": 63957
+ },
+ {
+ "date": "2026-06-05",
+ "stars": 64352
+ },
+ {
+ "date": "2026-06-06",
+ "stars": 64705
+ },
+ {
+ "date": "2026-06-07",
+ "stars": 64892
+ },
+ {
+ "date": "2026-06-08",
+ "stars": 65095
+ },
+ {
+ "date": "2026-06-09",
+ "stars": 65280
+ },
+ {
+ "date": "2026-06-10",
+ "stars": 65552
+ },
+ {
+ "date": "2026-06-11",
+ "stars": 65807
+ },
+ {
+ "date": "2026-06-12",
+ "stars": 65963
+ },
+ {
+ "date": "2026-06-13",
+ "stars": 66069
+ },
+ {
+ "date": "2026-06-14",
+ "stars": 66175
+ },
+ {
+ "date": "2026-06-15",
+ "stars": 66280
+ },
+ {
+ "date": "2026-06-16",
+ "stars": 66358
+ },
+ {
+ "date": "2026-06-17",
+ "stars": 66457
+ },
+ {
+ "date": "2026-06-18",
+ "stars": 66537
+ },
+ {
+ "date": "2026-06-19",
+ "stars": 66594
+ },
+ {
+ "date": "2026-06-20",
+ "stars": 66640
+ },
+ {
+ "date": "2026-06-21",
+ "stars": 66703
+ },
+ {
+ "date": "2026-06-22",
+ "stars": 66792
+ },
+ {
+ "date": "2026-06-23",
+ "stars": 66863
+ },
+ {
+ "date": "2026-06-24",
+ "stars": 66943
+ },
+ {
+ "date": "2026-06-25",
+ "stars": 67021
+ },
+ {
+ "date": "2026-06-26",
+ "stars": 67107
+ },
+ {
+ "date": "2026-06-27",
+ "stars": 67199
+ },
+ {
+ "date": "2026-06-28",
+ "stars": 67276
+ },
+ {
+ "date": "2026-06-29",
+ "stars": 67351
+ },
+ {
+ "date": "2026-06-30",
+ "stars": 67430
+ },
+ {
+ "date": "2026-07-01",
+ "stars": 67497
+ },
+ {
+ "date": "2026-07-02",
+ "stars": 67586
+ },
+ {
+ "date": "2026-07-03",
+ "stars": 67664
+ },
+ {
+ "date": "2026-07-04",
+ "stars": 67720
+ },
+ {
+ "date": "2026-07-05",
+ "stars": 67775
+ },
+ {
+ "date": "2026-07-06",
+ "stars": 67842
+ },
+ {
+ "date": "2026-07-07",
+ "stars": 67986
+ },
+ {
+ "date": "2026-07-08",
+ "stars": 68088
+ },
+ {
+ "date": "2026-07-09",
+ "stars": 68192
+ },
+ {
+ "date": "2026-07-10",
+ "stars": 68262
+ },
+ {
+ "date": "2026-07-11",
+ "stars": 68330
+ },
+ {
+ "date": "2026-07-12",
+ "stars": 68384
+ },
+ {
+ "date": "2026-07-13",
+ "stars": 68443
+ },
+ {
+ "date": "2026-07-14",
+ "stars": 68512
+ },
+ {
+ "date": "2026-07-15",
+ "stars": 68582
+ },
+ {
+ "date": "2026-07-16",
+ "stars": 68640
+ },
+ {
+ "date": "2026-07-17",
+ "stars": 68714
+ },
+ {
+ "date": "2026-07-18",
+ "stars": 68766
+ },
+ {
+ "date": "2026-07-19",
+ "stars": 68901
+ },
+ {
+ "date": "2026-07-20",
+ "stars": 68993
+ }
+ ],
+ "generated_at": "2026-07-21T07:30:41Z",
+ "method": "current_stargazers_starred_at"
+ },
+ "repository": "666ghj/MiroFish",
+ "schema_version": 1,
+ "snapshots": [
+ {
+ "at": "2026-07-21T07:46:01Z",
+ "stars": 69026
+ }
+ ],
+ "timezone": "UTC"
+}
diff --git a/.github/workflows/update-star-history.yml b/.github/workflows/update-star-history.yml
new file mode 100644
index 00000000..756d939f
--- /dev/null
+++ b/.github/workflows/update-star-history.yml
@@ -0,0 +1,322 @@
+name: Update Star History
+
+on:
+ schedule:
+ - cron: '17 3 1,16 * *'
+ timezone: 'UTC'
+ workflow_dispatch:
+permissions:
+ contents: read
+
+concurrency:
+ group: repository-star-history-${{ github.repository_id }}
+ cancel-in-progress: false
+
+jobs:
+ update-default-branch:
+ if: >-
+ ${{
+ github.repository == '666ghj/MiroFish' &&
+ github.ref_name == github.event.repository.default_branch &&
+ (
+ github.event_name == 'schedule' ||
+ (
+ github.event_name == 'workflow_dispatch' &&
+ github.actor_id == '110395318' &&
+ github.triggering_actor == '666ghj'
+ )
+ )
+ }}
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ permissions:
+ contents: write
+ env:
+ GIT_TERMINAL_PROMPT: '0'
+ EXPECTED_REPOSITORY: '666ghj/MiroFish'
+ EXPECTED_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ steps:
+ - name: Fetch triggering public commit without credentials
+ shell: bash
+ env:
+ GITHUB_TOKEN: ''
+ GH_TOKEN: ''
+ run: |
+ set -euo pipefail
+ [[ "${GITHUB_REPOSITORY,,}" == "${EXPECTED_REPOSITORY,,}" ]]
+ [[ "$GITHUB_REF" == "refs/heads/$EXPECTED_DEFAULT_BRANCH" ]]
+ git init .
+ git remote add origin 'https://github.com/666ghj/MiroFish.git'
+ git \
+ -c credential.helper= \
+ -c http.followRedirects=false \
+ fetch \
+ --no-tags \
+ --depth=1 \
+ origin \
+ "$GITHUB_REF"
+ [[ "$(git rev-parse FETCH_HEAD)" == "$GITHUB_SHA" ]]
+ git -c core.hooksPath=/dev/null checkout --detach "$GITHUB_SHA"
+ [[ -z "$(git status --porcelain --untracked-files=all)" ]]
+
+ - name: Run Star History tests without tokens
+ env:
+ GITHUB_TOKEN: ''
+ GH_TOKEN: ''
+ run: >-
+ python3 -m unittest
+ tests.test_local_star_history
+ tests.test_local_star_count_fetch
+ -v
+
+ - name: Fetch aggregate Star count only
+ shell: bash
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ umask 077
+ printf '%s %s\n' \
+ 'dfe9e0060d9abb0b3e1cda61bd73bba77fe878815adcbea14601666dce30e927' \
+ 'scripts/fetch_star_count.py' |
+ sha256sum --check --strict -
+ output="$RUNNER_TEMP/repository-star-count.txt"
+ [[ ! -e "$output" && ! -L "$output" ]]
+ python3 scripts/fetch_star_count.py > "$output"
+ [[ -f "$output" && ! -L "$output" ]]
+ (( $(wc -c < "$output") <= 32 ))
+ mapfile -t lines < "$output"
+ (( ${#lines[@]} == 1 ))
+ [[ "${lines[0]}" =~ ^[0-9]+$ ]]
+
+ - name: Record scheduled aggregate Star snapshot offline without tokens
+ shell: bash
+ env:
+ STAR_COUNT_FILE: ${{ runner.temp }}/repository-star-count.txt
+ GITHUB_TOKEN: ''
+ GH_TOKEN: ''
+ run: |
+ set -euo pipefail
+ trap 'rm -f -- "$STAR_COUNT_FILE"' EXIT
+ [[ -z "${GITHUB_TOKEN:-}" && -z "${GH_TOKEN:-}" ]]
+ python3 scripts/star_history.py record \
+ --count-file "$STAR_COUNT_FILE" \
+ --force
+
+ - name: Verify generated outputs without tokens
+ env:
+ GITHUB_TOKEN: ''
+ GH_TOKEN: ''
+ run: |
+ python3 scripts/star_history.py check
+ python3 -m unittest \
+ tests.test_local_star_history \
+ tests.test_local_star_count_fetch \
+ -v
+
+ - name: Commit exact output allowlist
+ id: commit
+ shell: bash
+ env:
+ GITHUB_TOKEN: ''
+ GH_TOKEN: ''
+ run: |
+ set -euo pipefail
+
+ allowed() {
+ case "$1" in
+ .github/star-history/history.json|\
+ static/image/star-history-light.svg|\
+ static/image/star-history-dark.svg) return 0 ;;
+ *) return 1 ;;
+ esac
+ }
+
+ bad=0
+ while IFS= read -r -d '' path; do
+ if ! allowed "$path"; then
+ printf '::error::Unexpected changed path: %q\n' "$path"
+ bad=1
+ fi
+ done < <(
+ git diff --name-only -z
+ git diff --cached --name-only -z
+ git ls-files --others --exclude-standard -z
+ )
+ (( bad == 0 )) || exit 1
+
+ for path in \
+ .github/star-history/history.json \
+ static/image/star-history-light.svg \
+ static/image/star-history-dark.svg
+ do
+ [[ -f "$path" && ! -L "$path" && -s "$path" ]] || {
+ printf '::error::Invalid output file: %s\n' "$path"
+ exit 1
+ }
+ [[ "$(realpath -e -- "$path")" == "$GITHUB_WORKSPACE/$path" ]] || {
+ printf '::error::Output escaped workspace: %s\n' "$path"
+ exit 1
+ }
+ done
+
+ git add -- \
+ .github/star-history/history.json \
+ static/image/star-history-light.svg \
+ static/image/star-history-dark.svg
+
+ if git diff --cached --quiet; then
+ printf 'created=false\n' >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ count=0
+ while IFS= read -r -d '' path; do
+ allowed "$path" || exit 1
+ ((count += 1))
+ done < <(git diff --cached --name-only -z)
+ (( count > 0 )) || exit 1
+
+ git config user.name 'github-actions[bot]'
+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
+ git \
+ -c commit.gpgsign=false \
+ -c core.hooksPath=/dev/null \
+ commit \
+ -m 'chore: update star history [skip ci]'
+ printf 'created=true\n' >> "$GITHUB_OUTPUT"
+
+ - name: Verify one allowlisted commit and unchanged main target
+ if: ${{ steps.commit.outputs.created == 'true' }}
+ shell: bash
+ env:
+ GITHUB_TOKEN: ''
+ GH_TOKEN: ''
+ run: |
+ set -euo pipefail
+
+ allowed() {
+ case "$1" in
+ .github/star-history/history.json|\
+ static/image/star-history-light.svg|\
+ static/image/star-history-dark.svg) return 0 ;;
+ *) return 1 ;;
+ esac
+ }
+
+ base="$GITHUB_SHA"
+ target_ref="$GITHUB_REF"
+ [[ "${GITHUB_REPOSITORY,,}" == "${EXPECTED_REPOSITORY,,}" ]]
+ [[ "$GITHUB_REF" == "refs/heads/$EXPECTED_DEFAULT_BRANCH" ]]
+ [[ "$(git rev-parse HEAD^)" == "$base" ]]
+ [[ "$(git rev-list --count "${base}..HEAD")" == 1 ]]
+ [[ -z "$(git status --porcelain --untracked-files=all)" ]]
+
+ origin="$(git remote get-url origin)"
+ case "$origin" in
+ https://github.com/666ghj/MiroFish|\
+ https://github.com/666ghj/MiroFish.git) ;;
+ *)
+ echo "::error::Unexpected origin"
+ exit 1
+ ;;
+ esac
+ mapfile -t push_urls < <(git remote get-url --push --all origin)
+ (( ${#push_urls[@]} == 1 ))
+ [[ "${push_urls[0]}" == "$origin" ]]
+
+ count=0
+ while IFS= read -r -d '' path; do
+ allowed "$path" || {
+ printf '::error::Unexpected committed path: %q\n' "$path"
+ exit 1
+ }
+ ((count += 1))
+ done < <(git diff-tree --no-commit-id --name-only -r -z HEAD)
+ (( count > 0 )) || exit 1
+
+ git \
+ -c credential.helper= \
+ -c http.followRedirects=false \
+ fetch \
+ --no-tags \
+ --depth=1 \
+ origin \
+ "$target_ref"
+ [[ "$(git rev-parse FETCH_HEAD)" == "$base" ]] || {
+ echo "::error::Target advanced; refusing to rebase or overwrite"
+ exit 1
+ }
+
+ - name: Push one allowlisted commit with an ephemeral credential
+ if: ${{ steps.commit.outputs.created == 'true' }}
+ shell: bash
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ GIT_TERMINAL_PROMPT: '0'
+ GIT_TRACE: '0'
+ GIT_TRACE_CURL: '0'
+ GIT_TRACE_PACKET: '0'
+ GIT_CURL_VERBOSE: '0'
+ run: |
+ set -euo pipefail
+
+ allowed() {
+ case "$1" in
+ .github/star-history/history.json|\
+ static/image/star-history-light.svg|\
+ static/image/star-history-dark.svg) return 0 ;;
+ *) return 1 ;;
+ esac
+ }
+
+ base="$GITHUB_SHA"
+ [[ "${GITHUB_REPOSITORY,,}" == "${EXPECTED_REPOSITORY,,}" ]]
+ [[ "$GITHUB_REF" == "refs/heads/$EXPECTED_DEFAULT_BRANCH" ]]
+ [[ "$(git rev-parse HEAD^)" == "$base" ]]
+ [[ "$(git rev-list --count "${base}..HEAD")" == 1 ]]
+ [[ -z "$(git status --porcelain --untracked-files=all)" ]]
+
+ origin="$(git remote get-url origin)"
+ case "$origin" in
+ https://github.com/666ghj/MiroFish|\
+ https://github.com/666ghj/MiroFish.git) ;;
+ *)
+ echo "::error::Unexpected origin"
+ exit 1
+ ;;
+ esac
+ mapfile -t push_urls < <(git remote get-url --push --all origin)
+ (( ${#push_urls[@]} == 1 ))
+ [[ "${push_urls[0]}" == "$origin" ]]
+
+ count=0
+ while IFS= read -r -d '' path; do
+ allowed "$path" || {
+ printf '::error::Unexpected committed path: %q\n' "$path"
+ exit 1
+ }
+ ((count += 1))
+ done < <(git diff-tree --no-commit-id --name-only -r -z HEAD)
+ (( count > 0 )) || exit 1
+
+ [[ -n "$GITHUB_TOKEN" ]]
+ [[ "$GITHUB_TOKEN" != *$'\n'* && "$GITHUB_TOKEN" != *$'\r'* ]]
+ encoded="$(
+ printf 'x-access-token:%s' "$GITHUB_TOKEN" |
+ base64 |
+ tr -d '\n'
+ )"
+ export GIT_CONFIG_COUNT=1
+ export GIT_CONFIG_KEY_0="http.${origin}.extraheader"
+ export GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $encoded"
+ unset encoded GITHUB_TOKEN
+ trap 'unset GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0' EXIT
+
+ git \
+ -c core.hooksPath=/dev/null \
+ -c credential.helper= \
+ push \
+ --porcelain \
+ origin \
+ "HEAD:$GITHUB_REF"
diff --git a/README-ZH.md b/README-ZH.md
index 13fbcb4d..0c80098d 100644
--- a/README-ZH.md
+++ b/README-ZH.md
@@ -194,10 +194,10 @@ MiroFish 的仿真引擎由 **[OASIS](https://github.com/camel-ai/oasis)** 驱
## 📈 项目统计
-
+
-
-
-
+
+
+
diff --git a/README.md b/README.md
index de082935..5e8071d0 100644
--- a/README.md
+++ b/README.md
@@ -194,10 +194,10 @@ MiroFish's simulation engine is powered by **[OASIS (Open Agent Social Interacti
## 📈 Project Statistics
-
+
-
-
-
+
+
+
-
\ No newline at end of file
+
diff --git a/scripts/fetch_star_count.py b/scripts/fetch_star_count.py
new file mode 100755
index 00000000..7b9851eb
--- /dev/null
+++ b/scripts/fetch_star_count.py
@@ -0,0 +1,146 @@
+#!/usr/bin/env python3
+"""Fetch one repository's aggregate GitHub Star count without loading the renderer.
+
+The successful stdout contract is deliberately tiny: one non-negative decimal
+integer followed by a newline. Errors are fixed, sanitized messages on stderr.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+import urllib.error
+import urllib.request
+from typing import Any
+
+
+REPOSITORY = "666ghj/MiroFish"
+API_URL = f"https://api.github.com/repos/{REPOSITORY}"
+API_VERSION = "2026-03-10"
+MAX_HTTP_BYTES = 1_000_000
+TIMEOUT_SECONDS = 20
+
+
+class FetchError(RuntimeError):
+ """A safe error whose message never includes response or secret data."""
+
+
+class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
+ """Refuse every redirect so credentials cannot be forwarded elsewhere."""
+
+ def redirect_request(self, *_args: Any, **_kwargs: Any) -> None:
+ return None
+
+
+def _build_opener() -> urllib.request.OpenerDirector:
+ return urllib.request.build_opener(NoRedirectHandler())
+
+
+def _status_error(status: int) -> FetchError:
+ if status in {301, 302, 303, 307, 308}:
+ return FetchError("GitHub API redirect was refused")
+ if status == 401:
+ return FetchError("GitHub API authentication failed")
+ if status == 403:
+ return FetchError("GitHub API request was denied")
+ if status == 404:
+ return FetchError("repository metadata was not found")
+ if status == 429:
+ return FetchError("GitHub API rate limit was exhausted")
+ if 500 <= status <= 599:
+ return FetchError("GitHub API is unavailable")
+ return FetchError("GitHub API request failed")
+
+
+def _read_response(response: Any) -> bytes:
+ raw_length = response.headers.get("Content-Length")
+ if raw_length is not None:
+ try:
+ content_length = int(raw_length, 10)
+ except (TypeError, ValueError) as exc:
+ raise FetchError("GitHub API returned invalid response metadata") from exc
+ if content_length < 0 or content_length > MAX_HTTP_BYTES:
+ raise FetchError("GitHub API response exceeded the size limit")
+
+ payload = response.read(MAX_HTTP_BYTES + 1)
+ if len(payload) > MAX_HTTP_BYTES:
+ raise FetchError("GitHub API response exceeded the size limit")
+ return payload
+
+
+def fetch_star_count(token: str, opener: Any | None = None) -> int:
+ if not token or len(token) > 4_096 or "\r" in token or "\n" in token:
+ raise FetchError("GITHUB_TOKEN is missing or invalid")
+
+ request = urllib.request.Request(
+ API_URL,
+ headers={
+ "Accept": "application/vnd.github+json",
+ "Authorization": f"Bearer {token}",
+ "User-Agent": "Repository-Star-History-Fetcher",
+ "X-GitHub-Api-Version": API_VERSION,
+ },
+ method="GET",
+ )
+ client = opener or _build_opener()
+ try:
+ response = client.open(request, timeout=TIMEOUT_SECONDS)
+ except urllib.error.HTTPError as exc:
+ status = exc.code
+ exc.close()
+ raise _status_error(status) from None
+ except (urllib.error.URLError, TimeoutError, OSError):
+ raise FetchError("GitHub API network request failed") from None
+ except Exception:
+ raise FetchError("GitHub API request could not be started") from None
+
+ try:
+ with response:
+ if response.geturl() != API_URL:
+ raise FetchError("GitHub API redirect was refused")
+ status = response.getcode()
+ if status != 200:
+ raise _status_error(status)
+ payload = _read_response(response)
+ except FetchError:
+ raise
+ except (TimeoutError, OSError):
+ raise FetchError("GitHub API response could not be read") from None
+ except Exception:
+ raise FetchError("GitHub API response could not be processed") from None
+
+ try:
+ document = json.loads(payload)
+ except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
+ raise FetchError("GitHub API returned malformed JSON") from None
+ if not isinstance(document, dict):
+ raise FetchError("GitHub API response had an unexpected shape")
+
+ count = document.get("stargazers_count")
+ if type(count) is not int or count < 0:
+ raise FetchError("GitHub API returned an invalid stargazers_count")
+ return count
+
+
+def main(argv: list[str] | None = None) -> int:
+ arguments = sys.argv[1:] if argv is None else argv
+ if arguments:
+ print("error: this command accepts no arguments", file=sys.stderr)
+ return 2
+
+ try:
+ count = fetch_star_count(os.environ.get("GITHUB_TOKEN", ""))
+ except FetchError as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 1
+ except Exception:
+ print("error: unexpected internal failure", file=sys.stderr)
+ return 1
+
+ print(count)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/star_history.py b/scripts/star_history.py
new file mode 100755
index 00000000..de564db7
--- /dev/null
+++ b/scripts/star_history.py
@@ -0,0 +1,1489 @@
+#!/usr/bin/env python3
+"""Generate and maintain a repository-owned Star History chart.
+
+The one-time ``backfill`` command queries only opaque edge cursors and
+``starredAt`` timestamps. Scheduled updates receive one aggregate count from the
+standalone fetch-only helper, then record and render without credentials. No
+stargazer identity is persisted.
+"""
+
+from __future__ import annotations
+
+import argparse
+import base64
+import hashlib
+import html
+import json
+import math
+import os
+import re
+import stat
+import subprocess
+import sys
+import tempfile
+import xml.etree.ElementTree as ET
+from collections import Counter
+from dataclasses import dataclass
+from datetime import date, datetime, time, timedelta, timezone
+from pathlib import Path
+from typing import Any, Mapping, Protocol, Sequence
+
+
+REPOSITORY = "666ghj/MiroFish"
+REPOSITORY_OWNER, REPOSITORY_NAME = REPOSITORY.split("/", 1)
+INTERVAL_DAYS = 13
+STATE_RELATIVE = Path(".github/star-history/history.json")
+LIGHT_SVG_RELATIVE = Path("static/image/star-history-light.svg")
+DARK_SVG_RELATIVE = Path("static/image/star-history-dark.svg")
+OUTPUT_RELATIVES = (STATE_RELATIVE, LIGHT_SVG_RELATIVE, DARK_SVG_RELATIVE)
+MAX_STATE_BYTES = 5_000_000
+MAX_COUNT_FILE_BYTES = 64
+MAX_STAR_COUNT = (1 << 63) - 1
+PAGE_SIZE = 100
+RATE_LIMIT_RESERVE = 20
+UTC = timezone.utc
+
+# A reviewed snapshot of the repository owner's public GitHub avatar. The
+# bootstrapper injects the bounded PNG/JPEG bytes and their digest into this
+# template so scheduled rendering remains offline and the SVG self-contained.
+OWNER_AVATAR_BASE64 = "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"
+OWNER_AVATAR_MEDIA_TYPE = "image/jpeg"
+OWNER_AVATAR_DATA_URI = (
+ f"data:{OWNER_AVATAR_MEDIA_TYPE};base64,{OWNER_AVATAR_BASE64}"
+)
+OWNER_AVATAR_SHA256 = "0b469b43ffc2e2dad3ea63970b3483f38c0199162ff46819cfa19a0237bb9072"
+OWNER_AVATAR_DIMENSIONS = (64, 64)
+MAX_INLINE_AVATAR_BYTES = 64_000
+
+# Star History's reviewed 64x64 RGBA watermark icon, embedded exactly as used
+# by the upstream MIT-licensed renderer (Copyright 2025 Star History). The
+# corresponding license notice is retained in THIRD_PARTY_NOTICES.md.
+WATERMARK_LOGO_BASE64 = (
+ "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"
+ "MjY6MTQtMDU6MDDygtFyAAAAAElFTkSuQmCC"
+)
+WATERMARK_LOGO_DATA_URI = f"data:image/png;base64,{WATERMARK_LOGO_BASE64}"
+WATERMARK_LOGO_SHA256 = "02d30436a381b85e3e8beda75b06bd40ab98b14a419f293688ef32931b639bad"
+WATERMARK_LOGO_DIMENSIONS = (64, 64)
+MAX_INLINE_WATERMARK_BYTES = 8_192
+
+STATE_TIMESTAMP_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
+GRAPHQL_QUERY = """\
+query StarTimes($owner: String!, $name: String!, $after: String) {
+ repository(owner: $owner, name: $name) {
+ stargazers(
+ first: 100
+ after: $after
+ orderBy: {field: STARRED_AT, direction: DESC}
+ ) {
+ totalCount
+ edges { cursor starredAt }
+ pageInfo { hasNextPage endCursor }
+ }
+ }
+ rateLimit { cost remaining resetAt }
+}
+"""
+
+
+class StarHistoryError(RuntimeError):
+ """A safe, user-facing error that never includes secrets."""
+
+
+@dataclass(frozen=True)
+class StargazerEdge:
+ cursor: str
+ starred_at: datetime
+
+
+@dataclass(frozen=True)
+class StargazerPage:
+ total_count: int
+ edges: tuple[StargazerEdge, ...]
+ has_next_page: bool
+ end_cursor: str | None
+ rate_remaining: int
+
+
+@dataclass(frozen=True)
+class Result:
+ changed: bool
+ due: bool | None
+ message: str
+
+
+class Clock(Protocol):
+ def now(self) -> datetime: ...
+
+
+class GitHubGateway(Protocol):
+ def fetch_stargazer_page(self, after: str | None) -> StargazerPage: ...
+
+
+class CommandRunner(Protocol):
+ def run(self, arguments: Sequence[str]) -> subprocess.CompletedProcess[str]: ...
+
+
+class SystemClock:
+ def now(self) -> datetime:
+ return datetime.now(UTC).replace(microsecond=0)
+
+
+class SubprocessCommandRunner:
+ def run(self, arguments: Sequence[str]) -> subprocess.CompletedProcess[str]:
+ try:
+ return subprocess.run(
+ list(arguments),
+ check=False,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ timeout=45,
+ )
+ except FileNotFoundError as exc:
+ raise StarHistoryError("GitHub CLI (gh) is required for backfill") from exc
+ except subprocess.TimeoutExpired as exc:
+ raise StarHistoryError("GitHub GraphQL request timed out") from exc
+
+
+class GhGraphQLGateway:
+ """Production adapter for the one-time, maintainer-authorized backfill."""
+
+ def __init__(self, runner: CommandRunner | None = None) -> None:
+ self._runner = runner or SubprocessCommandRunner()
+
+ def fetch_stargazer_page(self, after: str | None) -> StargazerPage:
+ arguments = [
+ "gh",
+ "api",
+ "graphql",
+ "-f",
+ f"query={GRAPHQL_QUERY}",
+ "-f",
+ f"owner={REPOSITORY_OWNER}",
+ "-f",
+ f"name={REPOSITORY_NAME}",
+ ]
+ if after is not None:
+ if not after or "\n" in after or "\r" in after:
+ raise StarHistoryError("GitHub returned an invalid pagination cursor")
+ arguments.extend(("-f", f"after={after}"))
+
+ completed = self._runner.run(arguments)
+ if completed.returncode != 0:
+ raise StarHistoryError(
+ f"GitHub GraphQL request failed (exit {completed.returncode})"
+ )
+ try:
+ payload = json.loads(completed.stdout)
+ except json.JSONDecodeError as exc:
+ raise StarHistoryError("GitHub GraphQL returned malformed JSON") from exc
+
+ if not isinstance(payload, dict) or payload.get("errors"):
+ raise StarHistoryError("GitHub GraphQL rejected the stargazer request")
+ try:
+ data = payload["data"]
+ repository = data["repository"]
+ stargazers = repository["stargazers"]
+ rate_limit = data["rateLimit"]
+ raw_edges = stargazers["edges"]
+ page_info = stargazers["pageInfo"]
+ except (KeyError, TypeError) as exc:
+ raise StarHistoryError("GitHub GraphQL response had an unexpected shape") from exc
+
+ if not all(
+ isinstance(value, dict)
+ for value in (data, repository, stargazers, rate_limit, page_info)
+ ):
+ raise StarHistoryError("GitHub GraphQL response had an unexpected shape")
+
+ total_count = _strict_non_negative_int(
+ stargazers.get("totalCount"), "GraphQL totalCount"
+ )
+ rate_remaining = _strict_non_negative_int(
+ rate_limit.get("remaining"), "GraphQL rate remaining"
+ )
+ if not isinstance(raw_edges, list):
+ raise StarHistoryError("GitHub GraphQL edges were not a list")
+
+ edges: list[StargazerEdge] = []
+ for raw_edge in raw_edges:
+ if not isinstance(raw_edge, dict):
+ raise StarHistoryError("GitHub GraphQL returned an invalid edge")
+ cursor = raw_edge.get("cursor")
+ starred_at = raw_edge.get("starredAt")
+ if not isinstance(cursor, str) or not cursor:
+ raise StarHistoryError("GitHub GraphQL returned an invalid edge cursor")
+ if not isinstance(starred_at, str):
+ raise StarHistoryError("GitHub GraphQL returned an invalid star timestamp")
+ edges.append(StargazerEdge(cursor, _parse_github_timestamp(starred_at)))
+
+ has_next_page = page_info.get("hasNextPage")
+ end_cursor = page_info.get("endCursor")
+ if type(has_next_page) is not bool:
+ raise StarHistoryError("GitHub GraphQL returned invalid page information")
+ if end_cursor is not None and not isinstance(end_cursor, str):
+ raise StarHistoryError("GitHub GraphQL returned an invalid page cursor")
+ if has_next_page and not end_cursor:
+ raise StarHistoryError("GitHub GraphQL omitted the next page cursor")
+
+ return StargazerPage(
+ total_count=total_count,
+ edges=tuple(edges),
+ has_next_page=has_next_page,
+ end_cursor=end_cursor,
+ rate_remaining=rate_remaining,
+ )
+
+
+def _strict_non_negative_int(value: Any, label: str) -> int:
+ if type(value) is not int or value < 0:
+ raise StarHistoryError(f"{label} must be a non-negative integer")
+ return value
+
+
+def _parse_github_timestamp(value: str) -> datetime:
+ try:
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except ValueError as exc:
+ raise StarHistoryError("GitHub returned an invalid star timestamp") from exc
+ if parsed.tzinfo is None or parsed.utcoffset() != timedelta(0):
+ raise StarHistoryError("GitHub star timestamp was not UTC")
+ return parsed.astimezone(UTC)
+
+
+def _parse_state_timestamp(value: Any, label: str) -> datetime:
+ if not isinstance(value, str) or not STATE_TIMESTAMP_RE.fullmatch(value):
+ raise StarHistoryError(f"{label} must use YYYY-MM-DDTHH:MM:SSZ")
+ try:
+ parsed = datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
+ except ValueError as exc:
+ raise StarHistoryError(f"{label} is not a valid UTC timestamp") from exc
+ return parsed
+
+
+def _format_state_timestamp(value: datetime) -> str:
+ normalized = _normalize_now(value)
+ return normalized.strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def _normalize_now(value: datetime) -> datetime:
+ if value.tzinfo is None or value.utcoffset() != timedelta(0):
+ raise StarHistoryError("clock must return a UTC datetime")
+ return value.astimezone(UTC).replace(microsecond=0)
+
+
+def _expect_keys(value: Mapping[str, Any], expected: set[str], label: str) -> None:
+ actual = set(value)
+ if actual != expected:
+ raise StarHistoryError(f"{label} contains missing or unknown fields")
+
+
+def validate_state(state: Any) -> None:
+ if not isinstance(state, dict):
+ raise StarHistoryError("history state must be a JSON object")
+ _expect_keys(
+ state,
+ {
+ "schema_version",
+ "repository",
+ "timezone",
+ "ongoing_interval_days",
+ "reconstruction",
+ "snapshots",
+ },
+ "history state",
+ )
+ if state["schema_version"] != 1 or type(state["schema_version"]) is not int:
+ raise StarHistoryError("unsupported history schema_version")
+ if state["repository"] != REPOSITORY:
+ raise StarHistoryError("history repository does not match configured repository")
+ if state["timezone"] != "UTC":
+ raise StarHistoryError("history timezone must be UTC")
+ if (
+ state["ongoing_interval_days"] != INTERVAL_DAYS
+ or type(state["ongoing_interval_days"]) is not int
+ ):
+ raise StarHistoryError(
+ f"history interval must be exactly {INTERVAL_DAYS} days"
+ )
+
+ reconstruction = state["reconstruction"]
+ if not isinstance(reconstruction, dict):
+ raise StarHistoryError("reconstruction must be an object")
+ _expect_keys(
+ reconstruction,
+ {"method", "generated_at", "daily"},
+ "reconstruction",
+ )
+ reconstruction_method = reconstruction["method"]
+ if reconstruction_method not in {
+ "current_stargazers_starred_at",
+ "aggregate_snapshot_only",
+ }:
+ raise StarHistoryError("unsupported reconstruction method")
+ generated_at = _parse_state_timestamp(
+ reconstruction["generated_at"], "reconstruction.generated_at"
+ )
+ daily = reconstruction["daily"]
+ if not isinstance(daily, list):
+ raise StarHistoryError("reconstruction.daily must be a list")
+ if reconstruction_method == "aggregate_snapshot_only" and daily:
+ raise StarHistoryError("aggregate-only history cannot contain reconstructed dates")
+
+ previous_day: date | None = None
+ previous_stars = 0
+ for index, raw_point in enumerate(daily):
+ if not isinstance(raw_point, dict):
+ raise StarHistoryError("reconstruction point must be an object")
+ _expect_keys(raw_point, {"date", "stars"}, "reconstruction point")
+ raw_date = raw_point["date"]
+ if not isinstance(raw_date, str):
+ raise StarHistoryError("reconstruction date must be a string")
+ try:
+ point_day = date.fromisoformat(raw_date)
+ except ValueError as exc:
+ raise StarHistoryError("reconstruction date is invalid") from exc
+ if point_day.isoformat() != raw_date:
+ raise StarHistoryError("reconstruction date is not canonical")
+ stars = _strict_non_negative_int(raw_point["stars"], "reconstruction stars")
+ if index == 0 and stars <= 0:
+ raise StarHistoryError("first reconstruction point must have stars")
+ if previous_day is not None and point_day <= previous_day:
+ raise StarHistoryError("reconstruction dates must be strictly increasing")
+ if index > 0 and stars <= previous_stars:
+ raise StarHistoryError("reconstruction stars must be strictly increasing")
+ if point_day >= generated_at.date():
+ raise StarHistoryError("reconstruction must contain only completed UTC dates")
+ previous_day = point_day
+ previous_stars = stars
+
+ snapshots = state["snapshots"]
+ if not isinstance(snapshots, list):
+ raise StarHistoryError("snapshots must be a list")
+ previous_snapshot: datetime | None = None
+ first_snapshot: datetime | None = None
+ for raw_snapshot in snapshots:
+ if not isinstance(raw_snapshot, dict):
+ raise StarHistoryError("snapshot must be an object")
+ _expect_keys(raw_snapshot, {"at", "stars"}, "snapshot")
+ snapshot_at = _parse_state_timestamp(raw_snapshot["at"], "snapshot.at")
+ _strict_non_negative_int(raw_snapshot["stars"], "snapshot stars")
+ if previous_snapshot is not None and snapshot_at <= previous_snapshot:
+ raise StarHistoryError("snapshot timestamps must be strictly increasing")
+ if first_snapshot is None:
+ first_snapshot = snapshot_at
+ previous_snapshot = snapshot_at
+
+ if first_snapshot is not None:
+ if first_snapshot < generated_at:
+ raise StarHistoryError("first snapshot cannot predate reconstruction")
+ if previous_day is not None and previous_day >= first_snapshot.date():
+ raise StarHistoryError("reconstruction dates must predate snapshots")
+
+
+def canonical_state_bytes(state: Mapping[str, Any]) -> bytes:
+ validate_state(state)
+ return (
+ json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
+ ).encode("utf-8")
+
+
+def _safe_workspace(workspace: Path) -> Path:
+ try:
+ root = workspace.resolve(strict=True)
+ except OSError as exc:
+ raise StarHistoryError("workspace does not exist") from exc
+ if not root.is_dir():
+ raise StarHistoryError("workspace is not a directory")
+ return root
+
+
+def _safe_target(workspace: Path, relative: Path, create_parent: bool) -> Path:
+ root = _safe_workspace(workspace)
+ if relative.is_absolute() or ".." in relative.parts:
+ raise StarHistoryError("output path escaped the workspace")
+
+ current = root
+ for part in relative.parts[:-1]:
+ current = current / part
+ if current.is_symlink():
+ raise StarHistoryError("output directory cannot be a symbolic link")
+ target = root / relative
+ if target.is_symlink():
+ raise StarHistoryError("output file cannot be a symbolic link")
+ if create_parent:
+ try:
+ target.parent.mkdir(parents=True, exist_ok=True)
+ except OSError as exc:
+ raise StarHistoryError("could not create output directory") from exc
+ current = root
+ for part in relative.parts[:-1]:
+ current = current / part
+ if current.is_symlink():
+ raise StarHistoryError("output directory cannot be a symbolic link")
+ if target.is_symlink():
+ raise StarHistoryError("output file cannot be a symbolic link")
+ try:
+ resolved_parent = target.parent.resolve(strict=False)
+ resolved_parent.relative_to(root)
+ except (OSError, ValueError) as exc:
+ raise StarHistoryError("output path escaped the workspace") from exc
+ return resolved_parent / target.name
+
+
+def _read_limited(path: Path, limit: int, label: str) -> bytes:
+ try:
+ with path.open("rb") as handle:
+ payload = handle.read(limit + 1)
+ except FileNotFoundError as exc:
+ raise StarHistoryError(f"{label} is missing") from exc
+ except OSError as exc:
+ raise StarHistoryError(f"could not read {label}") from exc
+ if len(payload) > limit:
+ raise StarHistoryError(f"{label} exceeded the size limit")
+ return payload
+
+
+def load_star_count_file(path: Path) -> int:
+ """Read a tiny, symlink-safe decimal count produced by the fetch-only step."""
+
+ flags = os.O_RDONLY
+ if hasattr(os, "O_NOFOLLOW"):
+ flags |= os.O_NOFOLLOW
+ try:
+ descriptor = os.open(path, flags)
+ except OSError as exc:
+ raise StarHistoryError("Star count file is missing or unsafe") from exc
+ try:
+ metadata = os.fstat(descriptor)
+ if not stat.S_ISREG(metadata.st_mode):
+ raise StarHistoryError("Star count file is not a regular file")
+ payload = os.read(descriptor, MAX_COUNT_FILE_BYTES + 1)
+ except OSError as exc:
+ raise StarHistoryError("could not read Star count file") from exc
+ finally:
+ os.close(descriptor)
+
+ if len(payload) > MAX_COUNT_FILE_BYTES:
+ raise StarHistoryError("Star count file exceeded the size limit")
+ if not re.fullmatch(rb"(?:0|[1-9][0-9]*)\n?", payload):
+ raise StarHistoryError("Star count file must contain one decimal integer")
+ count = int(payload)
+ if count > MAX_STAR_COUNT:
+ raise StarHistoryError("Star count exceeded the supported range")
+ return count
+
+
+def load_state(workspace: Path, require_canonical: bool = True) -> dict[str, Any]:
+ state_path = _safe_target(workspace, STATE_RELATIVE, create_parent=False)
+ payload = _read_limited(state_path, MAX_STATE_BYTES, "history state")
+ try:
+ state = json.loads(payload)
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise StarHistoryError("history state is not valid UTF-8 JSON") from exc
+ validate_state(state)
+ if require_canonical and canonical_state_bytes(state) != payload:
+ raise StarHistoryError("history state is not canonically formatted")
+ return state
+
+
+def _snapshot_due(state: Mapping[str, Any], now: datetime) -> bool:
+ normalized = _normalize_now(now)
+ generated_at = _parse_state_timestamp(
+ state["reconstruction"]["generated_at"], "reconstruction.generated_at"
+ )
+ if normalized < generated_at:
+ raise StarHistoryError("clock is earlier than the reconstruction timestamp")
+ snapshots = state["snapshots"]
+ if not snapshots:
+ return True
+ latest = _parse_state_timestamp(snapshots[-1]["at"], "snapshot.at")
+ if normalized < latest:
+ raise StarHistoryError("clock is earlier than the latest snapshot")
+ return normalized - latest >= timedelta(days=INTERVAL_DAYS)
+
+
+def _build_backfill_state(github: GitHubGateway, now: datetime) -> dict[str, Any]:
+ normalized_now = _normalize_now(now)
+ after: str | None = None
+ seen_page_cursors: set[str] = set()
+ seen_edge_cursors: set[str] = set()
+ daily_increments: Counter[date] = Counter()
+ initial_total: int | None = None
+ page_number = 0
+
+ while True:
+ page = github.fetch_stargazer_page(after)
+ page_number += 1
+ if initial_total is None:
+ initial_total = page.total_count
+ pages_required = math.ceil(initial_total / PAGE_SIZE)
+ remaining_requests = max(0, pages_required - 1)
+ if page.rate_remaining < remaining_requests + RATE_LIMIT_RESERVE:
+ raise StarHistoryError("insufficient GitHub GraphQL rate limit for backfill")
+ for edge in page.edges:
+ if edge.cursor in seen_edge_cursors:
+ raise StarHistoryError("GitHub GraphQL repeated an edge cursor")
+ seen_edge_cursors.add(edge.cursor)
+ if edge.starred_at.date() < normalized_now.date():
+ daily_increments[edge.starred_at.date()] += 1
+
+ if page.end_cursor is not None:
+ if page.end_cursor in seen_page_cursors:
+ raise StarHistoryError("GitHub GraphQL repeated a page cursor")
+ seen_page_cursors.add(page.end_cursor)
+ if not page.has_next_page:
+ break
+ if page.end_cursor is None:
+ raise StarHistoryError("GitHub GraphQL omitted the next page cursor")
+ after = page.end_cursor
+ if page_number > 10_000:
+ raise StarHistoryError("GitHub GraphQL exceeded the page safety limit")
+
+ if initial_total is None:
+ raise StarHistoryError("GitHub GraphQL returned no pages")
+ if len(seen_edge_cursors) != initial_total:
+ raise StarHistoryError("stargazer list changed or was incomplete during backfill")
+
+ running = 0
+ daily: list[dict[str, Any]] = []
+ for point_day in sorted(daily_increments):
+ running += daily_increments[point_day]
+ daily.append({"date": point_day.isoformat(), "stars": running})
+
+ state: dict[str, Any] = {
+ "schema_version": 1,
+ "repository": REPOSITORY,
+ "timezone": "UTC",
+ "ongoing_interval_days": INTERVAL_DAYS,
+ "reconstruction": {
+ "method": "current_stargazers_starred_at",
+ "generated_at": _format_state_timestamp(normalized_now),
+ "daily": daily,
+ },
+ "snapshots": [],
+ }
+ validate_state(state)
+ return state
+
+
+def _build_initial_state(now: datetime, stars: int) -> dict[str, Any]:
+ """Start an honest history when timestamp backfill is unavailable."""
+
+ normalized_now = _normalize_now(now)
+ checked_stars = _strict_non_negative_int(stars, "stargazers_count")
+ if checked_stars > MAX_STAR_COUNT:
+ raise StarHistoryError("Star count exceeded the supported range")
+ timestamp = _format_state_timestamp(normalized_now)
+ state: dict[str, Any] = {
+ "schema_version": 1,
+ "repository": REPOSITORY,
+ "timezone": "UTC",
+ "ongoing_interval_days": INTERVAL_DAYS,
+ "reconstruction": {
+ "method": "aggregate_snapshot_only",
+ "generated_at": timestamp,
+ "daily": [],
+ },
+ "snapshots": [{"at": timestamp, "stars": checked_stars}],
+ }
+ validate_state(state)
+ return state
+
+
+def _updated_with_snapshot(
+ state: Mapping[str, Any], now: datetime, stars: int
+) -> dict[str, Any]:
+ normalized_now = _normalize_now(now)
+ _strict_non_negative_int(stars, "stargazers_count")
+ updated = json.loads(json.dumps(state))
+ snapshots: list[dict[str, Any]] = updated["snapshots"]
+ new_snapshot = {
+ "at": _format_state_timestamp(normalized_now),
+ "stars": stars,
+ }
+ if snapshots:
+ latest_at = _parse_state_timestamp(snapshots[-1]["at"], "snapshot.at")
+ if normalized_now < latest_at:
+ raise StarHistoryError("clock is earlier than the latest snapshot")
+ if normalized_now.date() == latest_at.date():
+ if snapshots[-1]["stars"] == stars:
+ return updated
+ snapshots[-1] = new_snapshot
+ else:
+ snapshots.append(new_snapshot)
+ else:
+ snapshots.append(new_snapshot)
+ validate_state(updated)
+ return updated
+
+
+@dataclass(frozen=True)
+class ChartPoint:
+ at: datetime
+ stars: int
+ source: str
+
+
+def _chart_points(state: Mapping[str, Any]) -> list[ChartPoint]:
+ points: list[ChartPoint] = []
+ for item in state["reconstruction"]["daily"]:
+ point_day = date.fromisoformat(item["date"])
+ end_of_day = datetime.combine(point_day + timedelta(days=1), time.min, UTC)
+ points.append(ChartPoint(end_of_day, item["stars"], "reconstruction"))
+ for item in state["snapshots"]:
+ points.append(
+ ChartPoint(
+ _parse_state_timestamp(item["at"], "snapshot.at"),
+ item["stars"],
+ "snapshot",
+ )
+ )
+ points.sort(key=lambda point: point.at)
+ return points
+
+
+def _nice_y_axis(maximum: int) -> tuple[int, int]:
+ if maximum <= 0:
+ return 1, 5
+ raw = maximum / 5
+ exponent = math.floor(math.log10(raw)) if raw > 0 else 0
+ base = 10**exponent
+ fraction = raw / base
+ if fraction <= 1:
+ multiplier = 1.0
+ elif fraction <= 2:
+ multiplier = 2.0
+ elif fraction <= 2.5:
+ multiplier = 2.5
+ elif fraction <= 5:
+ multiplier = 5.0
+ else:
+ multiplier = 10.0
+ step = max(1, int(multiplier * base))
+ top = max(step, math.ceil(maximum / step) * step)
+ return step, top
+
+
+def _format_number(value: int) -> str:
+ if value >= 1_000_000:
+ return f"{value / 1_000_000:.1f}m".replace(".0m", "m")
+ if value >= 1_000:
+ return f"{value / 1_000:.1f}k".replace(".0k", "k")
+ return str(value)
+
+
+def _format_float(value: float) -> str:
+ rendered = f"{value:.2f}".rstrip("0").rstrip(".")
+ return rendered if rendered != "-0" else "0"
+
+
+def _x_tick_label(value: datetime, span_days: float) -> str:
+ months = (
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+ )
+ weekdays = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
+ if span_days >= 365:
+ return f"{months[value.month - 1]} {value.year}"
+ if span_days >= 14:
+ return f"{value.day:02d} {months[value.month - 1]}"
+ return f"{weekdays[value.weekday()]} {value.day:02d}"
+
+
+def _sign(value: float) -> int:
+ if value < 0:
+ return -1
+ if value > 0:
+ return 1
+ return 0
+
+
+def _monotone_x_path(points: Sequence[tuple[float, float]]) -> str:
+ """Return a D3 curveMonotoneX-equivalent SVG path.
+
+ D3 uses Steffen monotonic interpolation: interior tangents are limited so a
+ smooth cubic cannot overshoot a monotonic run. This small implementation
+ keeps the Star History curve shape without adding a JavaScript dependency.
+ """
+
+ normalized: list[tuple[float, float]] = []
+ for x, y in points:
+ if not math.isfinite(x) or not math.isfinite(y):
+ raise StarHistoryError("chart coordinates must be finite")
+ if normalized and x < normalized[-1][0]:
+ raise StarHistoryError("chart coordinates must be ordered")
+ if normalized and x == normalized[-1][0]:
+ normalized[-1] = (x, y)
+ else:
+ normalized.append((x, y))
+
+ if not normalized:
+ return ""
+
+ start_x, start_y = normalized[0]
+ commands = [f"M{_format_float(start_x)},{_format_float(start_y)}"]
+ if len(normalized) == 1:
+ return "".join(commands)
+ if len(normalized) == 2:
+ end_x, end_y = normalized[1]
+ commands.append(f"L{_format_float(end_x)},{_format_float(end_y)}")
+ return "".join(commands)
+
+ secants = [
+ (normalized[index + 1][1] - normalized[index][1])
+ / (normalized[index + 1][0] - normalized[index][0])
+ for index in range(len(normalized) - 1)
+ ]
+ tangents = [0.0] * len(normalized)
+ for index in range(1, len(normalized) - 1):
+ h0 = normalized[index][0] - normalized[index - 1][0]
+ h1 = normalized[index + 1][0] - normalized[index][0]
+ slope0 = secants[index - 1]
+ slope1 = secants[index]
+ weighted = (slope0 * h1 + slope1 * h0) / (h0 + h1)
+ tangents[index] = (_sign(slope0) + _sign(slope1)) * min(
+ abs(slope0), abs(slope1), 0.5 * abs(weighted)
+ )
+
+ tangents[0] = (3 * secants[0] - tangents[1]) / 2
+ tangents[-1] = (3 * secants[-1] - tangents[-2]) / 2
+
+ for index in range(len(normalized) - 1):
+ x0, y0 = normalized[index]
+ x1, y1 = normalized[index + 1]
+ third = (x1 - x0) / 3
+ control1_x = x0 + third
+ control1_y = y0 + third * tangents[index]
+ control2_x = x1 - third
+ control2_y = y1 - third * tangents[index + 1]
+ commands.append(
+ "C"
+ f"{_format_float(control1_x)},{_format_float(control1_y)} "
+ f"{_format_float(control2_x)},{_format_float(control2_y)} "
+ f"{_format_float(x1)},{_format_float(y1)}"
+ )
+ return "".join(commands)
+
+
+def render_svg(state: Mapping[str, Any], theme: str) -> bytes:
+ """Render the dependency-free Star History-compatible SVG.
+
+ The visual contract is a clean-room Python reimplementation of the MIT
+ licensed ``star-history/star-history`` renderer behavior reviewed for this
+ setup. No narayann7 JavaScript, npm package, or runtime dependency is
+ vendored or executed here.
+ """
+
+ validate_state(state)
+ if theme not in {"light", "dark"}:
+ raise StarHistoryError("unsupported SVG theme")
+
+ width = 800.0
+ height = 533.333
+ plot_left = 70.0
+ plot_top = 60.0
+ plot_width = 700.0
+ plot_height = 423.333
+ plot_bottom = plot_top + plot_height
+
+ if theme == "light":
+ background = "#ffffff"
+ foreground = "#000000"
+ legend_background = "#ffffff"
+ line_color = "#dd4528"
+ else:
+ background = "#0d1117"
+ foreground = "#ffffff"
+ legend_background = "#0d1117"
+ line_color = "#ff6b6b"
+
+ points = _chart_points(state)
+ generated_at = _parse_state_timestamp(
+ state["reconstruction"]["generated_at"], "reconstruction.generated_at"
+ )
+ if points:
+ x_min = points[0].at
+ x_max = points[-1].at
+ if x_max <= x_min:
+ # A one-instant history still needs a visible time domain, but the
+ # display padding must not become a fabricated zero-Star sample.
+ try:
+ x_min = points[0].at - timedelta(days=1)
+ except OverflowError:
+ pass
+ try:
+ x_max = points[-1].at + timedelta(days=1)
+ except OverflowError:
+ pass
+ maximum = max(point.stars for point in points)
+ else:
+ try:
+ x_min = generated_at - timedelta(days=1)
+ x_max = generated_at
+ except OverflowError:
+ x_min = generated_at
+ x_max = generated_at + timedelta(days=1)
+ maximum = 0
+
+ y_step, empty_y_top = _nice_y_axis(maximum)
+ y_domain = maximum if maximum > 0 else empty_y_top
+ x_span = max(1.0, (x_max - x_min).total_seconds())
+
+ def x_coord(value: datetime) -> float:
+ return plot_left + (
+ (value - x_min).total_seconds() / x_span
+ ) * plot_width
+
+ def y_coord(value: int) -> float:
+ return plot_bottom - (value / y_domain) * plot_height
+
+ line_coordinates = [
+ (x_coord(point.at), y_coord(point.stars)) for point in points
+ ]
+ if len({x for x, _ in line_coordinates}) == 1 and line_coordinates:
+ # SVG does not paint a path containing only a move command. Draw a
+ # small horizontal mark centred on the sole real sample instead.
+ x, y = line_coordinates[-1]
+ line_path = (
+ f"M{_format_float(x - 4)},{_format_float(y)}"
+ f"H{_format_float(x + 4)}"
+ )
+ else:
+ line_path = _monotone_x_path(line_coordinates)
+
+ y_ticks: list[str] = []
+ y_tick_limit = maximum if maximum > 0 else 5
+ for value in range(y_step, y_tick_limit + 1, y_step):
+ y = y_coord(value)
+ y_ticks.append(
+ f''
+ )
+ y_ticks.append(
+ f'{_format_number(value)}'
+ )
+
+ x_ticks: list[str] = []
+ seen_x_labels: set[str] = set()
+ span_days = (x_max - x_min).total_seconds() / 86400
+ tick_count = min(6, max(2, math.ceil(span_days) + 1))
+ for index in range(tick_count):
+ ratio = index / (tick_count - 1)
+ value = x_min + (x_max - x_min) * ratio
+ label = _x_tick_label(value, span_days)
+ if label in seen_x_labels:
+ continue
+ seen_x_labels.add(label)
+ x = x_coord(value)
+ if index == 0:
+ anchor = "start"
+ elif index == tick_count - 1:
+ anchor = "end"
+ else:
+ anchor = "middle"
+ x_ticks.append(
+ f''
+ f'{html.escape(label)}'
+ )
+
+ if state["reconstruction"]["method"] == "current_stargazers_starred_at":
+ description = (
+ f"Star history for {REPOSITORY}. Dates reconstructed from starredAt "
+ "timestamps and later aggregate snapshots are rendered as one continuous "
+ "series. No individual stargazer identity is stored."
+ )
+ else:
+ description = (
+ f"Star history for {REPOSITORY}. The series starts from the first locally "
+ "recorded aggregate snapshot. No individual stargazer identity is stored."
+ )
+ font = "xkcd"
+ legend_width = max(
+ len(REPOSITORY) * 7.5 + 8 + 21,
+ len(REPOSITORY) * 7 + 8 + 14 + 6,
+ )
+ svg = "".join(
+ [
+ f'\n",
+ ]
+ )
+ payload = svg.encode("utf-8")
+ _validate_svg(payload)
+ return payload
+
+
+def _reviewed_avatar_dimensions(payload: bytes) -> tuple[int, int] | None:
+ if OWNER_AVATAR_MEDIA_TYPE == "image/png":
+ if (
+ payload.startswith(b"\x89PNG\r\n\x1a\n")
+ and len(payload) >= 33
+ and payload[8:12] == (13).to_bytes(4, "big")
+ and payload[12:16] == b"IHDR"
+ and payload.endswith(b"IEND\xaeB`\x82")
+ ):
+ return (
+ int.from_bytes(payload[16:20], "big"),
+ int.from_bytes(payload[20:24], "big"),
+ )
+ return None
+ if OWNER_AVATAR_MEDIA_TYPE == "image/jpeg":
+ if not payload.startswith(b"\xff\xd8\xff") or not payload.endswith(b"\xff\xd9"):
+ return None
+ for marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF):
+ offset = payload.find(bytes((0xFF, marker)))
+ if offset >= 0 and offset + 9 <= len(payload):
+ return (
+ int.from_bytes(payload[offset + 7 : offset + 9], "big"),
+ int.from_bytes(payload[offset + 5 : offset + 7], "big"),
+ )
+ return None
+ return None
+
+
+def _validate_svg(payload: bytes) -> None:
+ try:
+ decoded_payload = payload.decode("utf-8", errors="strict")
+ except UnicodeDecodeError as exc:
+ raise StarHistoryError("generated SVG must be strict UTF-8") from exc
+ if decoded_payload.startswith("\ufeff") or "\x00" in decoded_payload:
+ raise StarHistoryError("generated SVG must be canonical UTF-8")
+ upper_payload = decoded_payload.upper()
+ if (
+ " MAX_INLINE_AVATAR_BYTES
+ or hashlib.sha256(avatar).hexdigest() != OWNER_AVATAR_SHA256
+ or _reviewed_avatar_dimensions(avatar) != OWNER_AVATAR_DIMENSIONS
+ ):
+ raise StarHistoryError("reviewed avatar data is invalid")
+ try:
+ watermark = base64.b64decode(WATERMARK_LOGO_BASE64, validate=True)
+ except ValueError as exc:
+ raise StarHistoryError("reviewed watermark data is invalid") from exc
+ png_header = (
+ watermark.startswith(b"\x89PNG\r\n\x1a\n")
+ and watermark[8:12] == (13).to_bytes(4, "big")
+ and watermark[12:16] == b"IHDR"
+ and len(watermark) >= 33
+ )
+ watermark_dimensions = (
+ (
+ int.from_bytes(watermark[16:20], "big"),
+ int.from_bytes(watermark[20:24], "big"),
+ )
+ if png_header
+ else None
+ )
+ if (
+ len(watermark) > MAX_INLINE_WATERMARK_BYTES
+ or not png_header
+ or watermark_dimensions != WATERMARK_LOGO_DIMENSIONS
+ or watermark[24:29] != bytes((8, 6, 0, 0, 0))
+ or not watermark.endswith(b"IEND\xaeB`\x82")
+ or hashlib.sha256(watermark).hexdigest() != WATERMARK_LOGO_SHA256
+ ):
+ raise StarHistoryError("reviewed watermark data is invalid")
+
+
+def _output_payloads(state: Mapping[str, Any]) -> dict[Path, bytes]:
+ return {
+ STATE_RELATIVE: canonical_state_bytes(state),
+ LIGHT_SVG_RELATIVE: render_svg(state, "light"),
+ DARK_SVG_RELATIVE: render_svg(state, "dark"),
+ }
+
+
+def _write_outputs(workspace: Path, state: Mapping[str, Any]) -> bool:
+ payloads = _output_payloads(state)
+ targets = {
+ relative: _safe_target(workspace, relative, create_parent=True)
+ for relative in OUTPUT_RELATIVES
+ }
+ if all(
+ target.exists() and target.read_bytes() == payloads[relative]
+ for relative, target in targets.items()
+ ):
+ return False
+
+ temporary_paths: dict[Path, Path] = {}
+ try:
+ for relative in OUTPUT_RELATIVES:
+ target = targets[relative]
+ with tempfile.NamedTemporaryFile(
+ mode="wb",
+ dir=target.parent,
+ prefix=f".{target.name}.",
+ suffix=".tmp",
+ delete=False,
+ ) as handle:
+ handle.write(payloads[relative])
+ handle.flush()
+ os.fsync(handle.fileno())
+ temporary_paths[relative] = Path(handle.name)
+ os.chmod(temporary_paths[relative], 0o644)
+
+ _validate_svg(temporary_paths[LIGHT_SVG_RELATIVE].read_bytes())
+ _validate_svg(temporary_paths[DARK_SVG_RELATIVE].read_bytes())
+ json.loads(temporary_paths[STATE_RELATIVE].read_bytes())
+
+ for relative in OUTPUT_RELATIVES:
+ checked_target = _safe_target(workspace, relative, create_parent=False)
+ if checked_target != targets[relative]:
+ raise StarHistoryError("output path changed during update")
+ os.replace(temporary_paths[relative], targets[relative])
+ temporary_paths.pop(relative, None)
+ except OSError as exc:
+ raise StarHistoryError("could not atomically replace Star History outputs") from exc
+ finally:
+ for temporary in temporary_paths.values():
+ try:
+ temporary.unlink(missing_ok=True)
+ except OSError:
+ pass
+
+ check_workspace(workspace)
+ return True
+
+
+def check_workspace(workspace: Path) -> None:
+ state = load_state(workspace, require_canonical=True)
+ expected = _output_payloads(state)
+ for relative in OUTPUT_RELATIVES[1:]:
+ target = _safe_target(workspace, relative, create_parent=False)
+ actual = _read_limited(target, MAX_STATE_BYTES, str(relative))
+ _validate_svg(actual)
+ if actual != expected[relative]:
+ raise StarHistoryError(f"{relative} is not synchronized with history.json")
+
+
+def execute(
+ command: str,
+ *,
+ github: GitHubGateway | None,
+ clock: Clock,
+ workspace: Path,
+ force: bool = False,
+ star_count: int | None = None,
+) -> Result:
+ root = _safe_workspace(workspace)
+ now = _normalize_now(clock.now())
+
+ if command == "backfill":
+ state_target = _safe_target(root, STATE_RELATIVE, create_parent=False)
+ if state_target.exists():
+ raise StarHistoryError("history state already exists; refusing to overwrite backfill")
+ if github is None:
+ raise StarHistoryError("GitHub access is required for backfill")
+ state = _build_backfill_state(github, now)
+ changed = _write_outputs(root, state)
+ return Result(changed, True, "historical Star data was reconstructed")
+
+ if command == "initialize":
+ state_target = _safe_target(root, STATE_RELATIVE, create_parent=False)
+ if state_target.exists():
+ raise StarHistoryError("history state already exists; refusing to overwrite it")
+ if star_count is None:
+ raise StarHistoryError("a fetched Star count is required for initialization")
+ state = _build_initial_state(now, star_count)
+ changed = _write_outputs(root, state)
+ return Result(changed, True, "aggregate-only Star history was initialized")
+
+ if command == "due":
+ state = load_state(root)
+ due = _snapshot_due(state, now)
+ return Result(False, due, "true" if due else "false")
+
+ if command == "record":
+ state = load_state(root)
+ due = _snapshot_due(state, now)
+ if not due and not force:
+ return Result(False, False, "snapshot is not due")
+ if star_count is None:
+ raise StarHistoryError("a fetched Star count is required for recording")
+ checked_count = _strict_non_negative_int(star_count, "stargazers_count")
+ if checked_count > MAX_STAR_COUNT:
+ raise StarHistoryError("Star count exceeded the supported range")
+ updated = _updated_with_snapshot(state, now, checked_count)
+ if updated == state:
+ return Result(False, due, "same-day snapshot is unchanged")
+ changed = _write_outputs(root, updated)
+ return Result(changed, due, "Star snapshot and charts were updated")
+
+ if command == "check":
+ check_workspace(root)
+ return Result(False, None, "Star History outputs are valid")
+
+ raise StarHistoryError("unknown Star History command")
+
+
+def _repository_root() -> Path:
+ return Path(__file__).resolve().parents[1]
+
+
+def _production_gateway(command: str) -> GitHubGateway | None:
+ if command == "backfill":
+ return GhGraphQLGateway()
+ return None
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ subparsers = parser.add_subparsers(dest="command", required=True)
+ subparsers.add_parser("backfill", help="reconstruct history using maintainer access")
+ initialize = subparsers.add_parser(
+ "initialize", help="start an honest history from one aggregate count"
+ )
+ initialize.add_argument(
+ "--count-file", required=True, type=Path, help="file containing one decimal count"
+ )
+ subparsers.add_parser(
+ "due", help=f"print whether a {INTERVAL_DAYS}-day snapshot is due"
+ )
+ record = subparsers.add_parser(
+ "record", help="apply a fetched aggregate count without GitHub credentials"
+ )
+ record.add_argument(
+ "--count-file", required=True, type=Path, help="file containing one decimal count"
+ )
+ record.add_argument(
+ "--force",
+ action="store_true",
+ help=f"record before {INTERVAL_DAYS} days",
+ )
+ subparsers.add_parser("check", help="verify state and deterministic SVG files")
+ return parser
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ arguments = build_parser().parse_args(argv)
+ try:
+ star_count = (
+ load_star_count_file(arguments.count_file)
+ if arguments.command in {"initialize", "record"}
+ else None
+ )
+ result = execute(
+ arguments.command,
+ github=_production_gateway(arguments.command),
+ clock=SystemClock(),
+ workspace=_repository_root(),
+ force=bool(getattr(arguments, "force", False)),
+ star_count=star_count,
+ )
+ except StarHistoryError as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 1
+ except Exception as exc: # Defensive: never print exception data that may hold a token.
+ print(f"error: unexpected internal error ({type(exc).__name__})", file=sys.stderr)
+ return 1
+
+ if arguments.command == "due":
+ print("true" if result.due else "false")
+ else:
+ print(result.message)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/static/image/star-history-dark.svg b/static/image/star-history-dark.svg
new file mode 100644
index 00000000..06bdb6dd
--- /dev/null
+++ b/static/image/star-history-dark.svg
@@ -0,0 +1 @@
+
diff --git a/static/image/star-history-light.svg b/static/image/star-history-light.svg
new file mode 100644
index 00000000..99d8f41a
--- /dev/null
+++ b/static/image/star-history-light.svg
@@ -0,0 +1 @@
+
diff --git a/tests/test_local_star_count_fetch.py b/tests/test_local_star_count_fetch.py
new file mode 100755
index 00000000..393a0c6e
--- /dev/null
+++ b/tests/test_local_star_count_fetch.py
@@ -0,0 +1,240 @@
+import ast
+import io
+import json
+import os
+import threading
+import unittest
+from contextlib import redirect_stderr, redirect_stdout
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from pathlib import Path
+from unittest.mock import patch
+
+from scripts import fetch_star_count
+
+
+TOKEN_SENTINEL = "TOKEN_FETCH_ONLY_DO_NOT_LEAK_7z9"
+
+
+class RecordingHandler(BaseHTTPRequestHandler):
+ def do_GET(self):
+ self.server.requests.append(
+ {"path": self.path, "headers": dict(self.headers.items())}
+ )
+ response = self.server.response
+ self.send_response(response["status"])
+ for name, value in response.get("headers", {}).items():
+ self.send_header(name, value)
+ self.end_headers()
+ try:
+ self.wfile.write(response.get("body", b""))
+ except BrokenPipeError:
+ pass
+
+ def log_message(self, *_args):
+ return
+
+
+class LocalHttpServer:
+ def __init__(self, *, status=200, headers=None, body=b""):
+ self.response = {
+ "status": status,
+ "headers": headers or {},
+ "body": body,
+ }
+
+ def __enter__(self):
+ self.server = HTTPServer(("127.0.0.1", 0), RecordingHandler)
+ self.server.requests = []
+ self.server.response = self.response
+ self.thread = threading.Thread(
+ target=self.server.serve_forever,
+ kwargs={"poll_interval": 0.01},
+ daemon=True,
+ )
+ self.thread.start()
+ host, port = self.server.server_address
+ self.url = f"http://{host}:{port}/repository"
+ return self
+
+ def __exit__(self, _exc_type, _exc, _traceback):
+ self.server.shutdown()
+ self.server.server_close()
+ self.thread.join(timeout=2)
+
+
+class FetchStarCountTests(unittest.TestCase):
+ def run_main(self, api_url):
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+ with (
+ patch.object(fetch_star_count, "API_URL", api_url),
+ patch.dict(os.environ, {"GITHUB_TOKEN": TOKEN_SENTINEL}, clear=False),
+ redirect_stdout(stdout),
+ redirect_stderr(stderr),
+ ):
+ exit_code = fetch_star_count.main([])
+ return exit_code, stdout.getvalue(), stderr.getvalue()
+
+ def test_fetcher_is_standalone_stdlib_only_and_has_fixed_api(self):
+ source_path = Path(fetch_star_count.__file__)
+ tree = ast.parse(source_path.read_text(encoding="utf-8"))
+ imported_roots = set()
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ imported_roots.update(alias.name.split(".", 1)[0] for alias in node.names)
+ elif isinstance(node, ast.ImportFrom) and node.module:
+ imported_roots.add(node.module.split(".", 1)[0])
+
+ self.assertNotIn("scripts", imported_roots)
+ self.assertNotIn("star_history", imported_roots)
+ self.assertEqual(
+ fetch_star_count.API_URL,
+ "https://api.github.com/repos/666ghj/MiroFish",
+ )
+
+ def test_success_stdout_is_only_one_decimal_count(self):
+ body = json.dumps({"stargazers_count": 41782}).encode()
+ with LocalHttpServer(body=body) as server:
+ exit_code, stdout, stderr = self.run_main(server.url)
+
+ self.assertEqual(exit_code, 0)
+ self.assertEqual(stdout, "41782\n")
+ self.assertEqual(stderr, "")
+ self.assertEqual(len(server.server.requests), 1)
+ request = server.server.requests[0]
+ self.assertEqual(request["path"], "/repository")
+ self.assertEqual(
+ request["headers"]["Authorization"], f"Bearer {TOKEN_SENTINEL}"
+ )
+
+ def test_redirect_is_refused_without_forwarding_token(self):
+ with LocalHttpServer(body=b'{"stargazers_count": 99}') as target:
+ with LocalHttpServer(
+ status=302,
+ headers={"Location": target.url},
+ body=f"unsafe-body {TOKEN_SENTINEL}".encode(),
+ ) as source:
+ exit_code, stdout, stderr = self.run_main(source.url)
+
+ self.assertEqual(exit_code, 1)
+ self.assertEqual(stdout, "")
+ self.assertEqual(target.server.requests, [])
+ self.assertIn("redirect was refused", stderr)
+ self.assertNotIn(TOKEN_SENTINEL, stderr)
+ self.assertNotIn("unsafe-body", stderr)
+
+ def test_malformed_oversized_and_invalid_counts_are_sanitized(self):
+ bodies = [
+ b"not-json " + TOKEN_SENTINEL.encode(),
+ b"x" * (fetch_star_count.MAX_HTTP_BYTES + 1),
+ json.dumps([]).encode(),
+ json.dumps({}).encode(),
+ json.dumps({"stargazers_count": True}).encode(),
+ json.dumps({"stargazers_count": -1}).encode(),
+ json.dumps({"stargazers_count": 1.5}).encode(),
+ json.dumps({"stargazers_count": "1"}).encode(),
+ ]
+ for body in bodies:
+ with self.subTest(body_prefix=body[:32]):
+ with LocalHttpServer(body=body) as server:
+ exit_code, stdout, stderr = self.run_main(server.url)
+
+ self.assertEqual(exit_code, 1)
+ self.assertEqual(stdout, "")
+ self.assertTrue(stderr.startswith("error: "))
+ self.assertNotIn(TOKEN_SENTINEL, stderr)
+ self.assertNotIn("not-json", stderr)
+
+ def test_status_and_network_errors_do_not_echo_exception_data(self):
+ with LocalHttpServer(
+ status=500,
+ body=f"unsafe-body {TOKEN_SENTINEL}".encode(),
+ ) as server:
+ exit_code, stdout, stderr = self.run_main(server.url)
+
+ self.assertEqual(exit_code, 1)
+ self.assertEqual(stdout, "")
+ self.assertIn("unavailable", stderr)
+ self.assertNotIn(TOKEN_SENTINEL, stderr)
+ self.assertNotIn("unsafe-body", stderr)
+
+ class FailingOpener:
+ def open(self, *_args, **_kwargs):
+ raise OSError(TOKEN_SENTINEL)
+
+ with self.assertRaises(fetch_star_count.FetchError) as captured:
+ fetch_star_count.fetch_star_count(TOKEN_SENTINEL, FailingOpener())
+ self.assertNotIn(TOKEN_SENTINEL, str(captured.exception))
+
+ def test_missing_or_newline_token_is_rejected_without_stdout(self):
+ for token in ("", "bad\ntoken", "bad\rtoken"):
+ with self.subTest(token=repr(token)):
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+ with (
+ patch.dict(os.environ, {"GITHUB_TOKEN": token}, clear=False),
+ redirect_stdout(stdout),
+ redirect_stderr(stderr),
+ ):
+ exit_code = fetch_star_count.main([])
+ self.assertEqual(exit_code, 1)
+ self.assertEqual(stdout.getvalue(), "")
+ if token:
+ self.assertNotIn(token, stderr.getvalue())
+
+ def test_workflow_keeps_credentials_out_of_record_and_render_steps(self):
+ repository = Path(__file__).resolve().parents[1]
+ workflow = (
+ repository / ".github/workflows/update-star-history.yml"
+ ).read_text(encoding="utf-8")
+ renderer = (repository / "scripts/star_history.py").read_text(
+ encoding="utf-8"
+ )
+
+ self.assertIn("cron: '17 3 1,16 * *'", workflow)
+ self.assertIn("timezone: 'UTC'", workflow)
+ self.assertNotIn("due-check:", workflow)
+ self.assertNotIn("star_history.py due", workflow)
+ self.assertNotIn("inputs.force", workflow)
+ self.assertNotIn("actions/checkout", workflow)
+ self.assertNotIn("uses:", workflow)
+ self.assertNotIn("pull_request:", workflow)
+ self.assertNotIn("pull_request_target:", workflow)
+ self.assertNotIn("workflow_run:", workflow)
+ self.assertNotIn("secrets.", workflow)
+ self.assertIn("sha256sum --check --strict", workflow)
+ self.assertIn("-c core.hooksPath=/dev/null", workflow)
+ self.assertNotIn("star_history.py sample", workflow)
+ self.assertNotIn('os.environ.get("GITHUB_TOKEN"', renderer)
+ self.assertNotIn('subparsers.add_parser("sample"', renderer)
+
+ token_steps = [
+ section
+ for section in workflow.split("\n - name: ")
+ if "GITHUB_TOKEN: ${{ github.token }}" in section
+ ]
+ self.assertGreaterEqual(len(token_steps), 2)
+ for section in token_steps:
+ step_name = section.splitlines()[0]
+ self.assertTrue(
+ step_name.startswith("Fetch aggregate Star count only")
+ or step_name.startswith(
+ "Push one allowlisted commit with an ephemeral credential"
+ )
+ )
+
+ record_steps = [
+ section
+ for section in workflow.split("\n - name: ")
+ if "star_history.py record" in section
+ ]
+ self.assertEqual(len(record_steps), 1)
+ for section in record_steps:
+ self.assertIn("GITHUB_TOKEN: ''", section)
+ self.assertIn("GH_TOKEN: ''", section)
+ self.assertNotIn("${{ github.token }}", section)
+ self.assertIn("--force", section)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_local_star_history.py b/tests/test_local_star_history.py
new file mode 100755
index 00000000..40649ee1
--- /dev/null
+++ b/tests/test_local_star_history.py
@@ -0,0 +1,954 @@
+import base64
+import hashlib
+import io
+import json
+import os
+import subprocess
+import tempfile
+import unittest
+import xml.etree.ElementTree as ET
+from contextlib import redirect_stderr, redirect_stdout
+from copy import deepcopy
+from datetime import datetime, timezone
+from pathlib import Path
+from unittest.mock import patch
+
+from scripts import star_history
+
+
+UTC = timezone.utc
+TOKEN_SENTINEL = "TOKEN_TEST_DO_NOT_LEAK_7z9"
+
+
+class FixedClock:
+ def __init__(self, value: str):
+ self.value = datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(
+ tzinfo=UTC
+ )
+
+ def now(self):
+ return self.value
+
+
+class FakeGitHub:
+ def __init__(self, pages=None):
+ self.pages = pages or {}
+ self.page_calls = []
+
+ def fetch_stargazer_page(self, after):
+ self.page_calls.append(after)
+ return self.pages[after]
+
+
+class FakeRunner:
+ def __init__(self, completed):
+ self.completed = completed
+ self.arguments = None
+
+ def run(self, arguments):
+ self.arguments = list(arguments)
+ return self.completed
+
+
+def edge(cursor, timestamp):
+ return star_history.StargazerEdge(
+ cursor,
+ datetime.fromisoformat(timestamp.replace("Z", "+00:00")),
+ )
+
+
+def page(total, edges, has_next=False, end_cursor=None, remaining=1_000):
+ return star_history.StargazerPage(
+ total_count=total,
+ edges=tuple(edges),
+ has_next_page=has_next,
+ end_cursor=end_cursor,
+ rate_remaining=remaining,
+ )
+
+
+def state_with_snapshots(snapshots=None):
+ return {
+ "schema_version": 1,
+ "repository": "666ghj/MiroFish",
+ "timezone": "UTC",
+ "ongoing_interval_days": 13,
+ "reconstruction": {
+ "method": "current_stargazers_starred_at",
+ "generated_at": "2026-07-01T00:00:00Z",
+ "daily": [],
+ },
+ "snapshots": snapshots or [],
+ }
+
+
+def seed_workspace(workspace, state):
+ state_path = workspace / ".github/star-history/history.json"
+ light_path = workspace / "static/image/star-history-light.svg"
+ dark_path = workspace / "static/image/star-history-dark.svg"
+ state_path.parent.mkdir(parents=True, exist_ok=True)
+ light_path.parent.mkdir(parents=True, exist_ok=True)
+ state_path.write_bytes(star_history.canonical_state_bytes(state))
+ light_path.write_bytes(star_history.render_svg(state, "light"))
+ dark_path.write_bytes(star_history.render_svg(state, "dark"))
+ return state_path, light_path, dark_path
+
+
+class StarHistoryBehaviorTests(unittest.TestCase):
+ def test_backfill_writes_hand_computed_completed_daily_history(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ workspace = Path(temporary)
+ edges = [
+ edge("c6", "2026-03-02T00:01:00Z"),
+ edge("c5", "2026-03-01T00:00:00Z"),
+ edge("c4", "2026-02-28T23:59:59Z"),
+ edge("c3", "2026-02-28T23:59:59Z"),
+ edge("c2", "2026-02-27T10:00:00Z"),
+ edge("c1", "2026-02-25T12:00:00Z"),
+ ]
+ github = FakeGitHub({None: page(6, edges)})
+
+ result = star_history.execute(
+ "backfill",
+ github=github,
+ clock=FixedClock("2026-03-02T12:00:00Z"),
+ workspace=workspace,
+ )
+
+ self.assertTrue(result.changed)
+ state = json.loads(
+ (workspace / ".github/star-history/history.json").read_text()
+ )
+ self.assertEqual(
+ state["reconstruction"]["daily"],
+ [
+ {"date": "2026-02-25", "stars": 1},
+ {"date": "2026-02-27", "stars": 2},
+ {"date": "2026-02-28", "stars": 4},
+ {"date": "2026-03-01", "stars": 5},
+ ],
+ )
+ self.assertEqual(state["snapshots"], [])
+ star_history.check_workspace(workspace)
+
+ def test_backfill_reads_101_edges_across_pages(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ workspace = Path(temporary)
+ january_first = [
+ edge(f"jan1-{index}", f"2026-01-01T00:00:{index % 60:02d}Z")
+ for index in range(100)
+ ]
+ first_edges = [edge("jan2", "2026-01-02T00:00:00Z")] + january_first[:99]
+ github = FakeGitHub(
+ {
+ None: page(101, first_edges, True, "next-page"),
+ "next-page": page(101, january_first[99:]),
+ }
+ )
+
+ star_history.execute(
+ "backfill",
+ github=github,
+ clock=FixedClock("2026-01-03T00:00:00Z"),
+ workspace=workspace,
+ )
+
+ state = json.loads(
+ (workspace / ".github/star-history/history.json").read_text()
+ )
+ self.assertEqual(github.page_calls, [None, "next-page"])
+ self.assertEqual(
+ state["reconstruction"]["daily"],
+ [
+ {"date": "2026-01-01", "stars": 100},
+ {"date": "2026-01-02", "stars": 101},
+ ],
+ )
+
+ def test_backfill_fails_closed_on_cursor_or_count_inconsistency(self):
+ cases = {
+ "duplicate edge": page(
+ 2,
+ [edge("same", "2026-01-01T00:00:00Z"), edge("same", "2026-01-02T00:00:00Z")],
+ ),
+ "count mismatch": page(2, [edge("only", "2026-01-01T00:00:00Z")]),
+ }
+ for label, first_page in cases.items():
+ with self.subTest(label=label), tempfile.TemporaryDirectory() as temporary:
+ workspace = Path(temporary)
+ with self.assertRaises(star_history.StarHistoryError):
+ star_history.execute(
+ "backfill",
+ github=FakeGitHub({None: first_page}),
+ clock=FixedClock("2026-01-03T00:00:00Z"),
+ workspace=workspace,
+ )
+ self.assertFalse(
+ (workspace / ".github/star-history/history.json").exists()
+ )
+
+ with tempfile.TemporaryDirectory() as temporary:
+ workspace = Path(temporary)
+ github = FakeGitHub(
+ {
+ None: page(
+ 2,
+ [edge("first", "2026-01-02T00:00:00Z")],
+ True,
+ "repeated-page",
+ ),
+ "repeated-page": page(
+ 2,
+ [edge("second", "2026-01-01T00:00:00Z")],
+ False,
+ "repeated-page",
+ ),
+ }
+ )
+ with self.assertRaises(star_history.StarHistoryError):
+ star_history.execute(
+ "backfill",
+ github=github,
+ clock=FixedClock("2026-01-03T00:00:00Z"),
+ workspace=workspace,
+ )
+ self.assertFalse(
+ (workspace / ".github/star-history/history.json").exists()
+ )
+
+ def test_backfill_accepts_exact_rate_limit_reserve_after_first_page(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ workspace = Path(temporary)
+ github = FakeGitHub(
+ {
+ None: page(
+ 1,
+ [edge("only", "2026-01-01T00:00:00Z")],
+ remaining=star_history.RATE_LIMIT_RESERVE,
+ )
+ }
+ )
+
+ result = star_history.execute(
+ "backfill",
+ github=github,
+ clock=FixedClock("2026-01-02T00:00:00Z"),
+ workspace=workspace,
+ )
+
+ self.assertTrue(result.changed)
+ self.assertEqual(github.page_calls, [None])
+
+ def test_backfill_refuses_dangling_output_symlink(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ workspace = Path(temporary)
+ state_path = workspace / ".github/star-history/history.json"
+ state_path.parent.mkdir(parents=True)
+ state_path.symlink_to(workspace / "missing-history.json")
+
+ with self.assertRaises(star_history.StarHistoryError):
+ star_history.execute(
+ "backfill",
+ github=FakeGitHub({None: page(0, [])}),
+ clock=FixedClock("2026-01-02T00:00:00Z"),
+ workspace=workspace,
+ )
+
+ self.assertTrue(state_path.is_symlink())
+
+ def test_initialize_starts_from_one_honest_aggregate_snapshot(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ workspace = Path(temporary)
+ result = star_history.execute(
+ "initialize",
+ github=None,
+ clock=FixedClock("2026-07-20T05:00:00Z"),
+ workspace=workspace,
+ star_count=42,
+ )
+
+ self.assertTrue(result.changed)
+ state = star_history.load_state(workspace)
+ self.assertEqual(
+ state["reconstruction"]["method"], "aggregate_snapshot_only"
+ )
+ self.assertEqual(state["reconstruction"]["daily"], [])
+ self.assertEqual(
+ state["snapshots"],
+ [{"at": "2026-07-20T05:00:00Z", "stars": 42}],
+ )
+ star_history.check_workspace(workspace)
+
+ def test_due_boundary_matches_configured_interval(self):
+ baseline = state_with_snapshots(
+ [{"at": "2026-07-20T05:00:00Z", "stars": 100}]
+ )
+ latest = datetime(2026, 7, 20, 5, 0, 0, tzinfo=UTC)
+ boundary = latest + star_history.timedelta(
+ days=star_history.INTERVAL_DAYS
+ )
+ cases = [
+ (
+ (boundary - star_history.timedelta(seconds=1)).strftime(
+ "%Y-%m-%dT%H:%M:%SZ"
+ ),
+ False,
+ ),
+ (boundary.strftime("%Y-%m-%dT%H:%M:%SZ"), True),
+ ]
+ for now, expected in cases:
+ with self.subTest(now=now), tempfile.TemporaryDirectory() as temporary:
+ workspace = Path(temporary)
+ seed_workspace(workspace, baseline)
+ result = star_history.execute(
+ "due",
+ github=None,
+ clock=FixedClock(now),
+ workspace=workspace,
+ )
+ self.assertIs(result.due, expected)
+
+ with tempfile.TemporaryDirectory() as temporary:
+ workspace = Path(temporary)
+ seed_workspace(workspace, state_with_snapshots())
+ result = star_history.execute(
+ "due",
+ github=None,
+ clock=FixedClock("2026-07-20T05:00:00Z"),
+ workspace=workspace,
+ )
+ self.assertTrue(result.due)
+
+ with tempfile.TemporaryDirectory() as temporary:
+ workspace = Path(temporary)
+ future_state = state_with_snapshots()
+ future_state["reconstruction"]["generated_at"] = "2026-08-01T00:00:00Z"
+ seed_workspace(workspace, future_state)
+ with self.assertRaises(star_history.StarHistoryError):
+ star_history.execute(
+ "due",
+ github=None,
+ clock=FixedClock("2026-07-20T05:00:00Z"),
+ workspace=workspace,
+ )
+
+ with tempfile.TemporaryDirectory() as temporary:
+ workspace = Path(temporary)
+ seed_workspace(workspace, baseline)
+ with self.assertRaises(star_history.StarHistoryError):
+ star_history.execute(
+ "due",
+ github=None,
+ clock=FixedClock("2026-07-20T04:59:59Z"),
+ workspace=workspace,
+ )
+
+ def test_record_before_due_does_not_write(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ workspace = Path(temporary)
+ state_path, light_path, dark_path = seed_workspace(
+ workspace,
+ state_with_snapshots(
+ [{"at": "2026-07-20T05:00:00Z", "stars": 100}]
+ ),
+ )
+ before = tuple(path.read_bytes() for path in (state_path, light_path, dark_path))
+ latest = datetime(2026, 7, 20, 5, 0, 0, tzinfo=UTC)
+ before_due = latest + star_history.timedelta(
+ days=star_history.INTERVAL_DAYS
+ ) - star_history.timedelta(seconds=1)
+ result = star_history.execute(
+ "record",
+ github=None,
+ clock=FixedClock(before_due.strftime("%Y-%m-%dT%H:%M:%SZ")),
+ workspace=workspace,
+ star_count=101,
+ )
+
+ self.assertFalse(result.changed)
+ self.assertEqual(
+ before,
+ tuple(path.read_bytes() for path in (state_path, light_path, dark_path)),
+ )
+
+ def test_record_applies_count_file_without_github_credentials(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ workspace = Path(temporary)
+ state_path, light_path, dark_path = seed_workspace(
+ workspace, state_with_snapshots()
+ )
+ count_file = workspace / "fetched-count"
+ count_file.write_bytes(b"123\n")
+
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+ with (
+ patch.dict(
+ os.environ,
+ {"GITHUB_TOKEN": TOKEN_SENTINEL, "GH_TOKEN": TOKEN_SENTINEL},
+ clear=False,
+ ),
+ patch.object(star_history, "_repository_root", return_value=workspace),
+ patch.object(
+ star_history,
+ "SystemClock",
+ return_value=FixedClock("2026-07-20T05:00:00Z"),
+ ),
+ redirect_stdout(stdout),
+ redirect_stderr(stderr),
+ ):
+ exit_code = star_history.main(
+ ["record", "--count-file", str(count_file), "--force"]
+ )
+
+ self.assertEqual(exit_code, 0)
+ self.assertEqual(stderr.getvalue(), "")
+ self.assertNotIn(TOKEN_SENTINEL, stdout.getvalue())
+ self.assertEqual(
+ star_history.load_state(workspace)["snapshots"],
+ [{"at": "2026-07-20T05:00:00Z", "stars": 123}],
+ )
+ for output in (state_path, light_path, dark_path):
+ self.assertNotIn(TOKEN_SENTINEL.encode(), output.read_bytes())
+ star_history.check_workspace(workspace)
+
+ def test_count_file_rejects_symlinks_malformed_and_extreme_values(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ valid = root / "valid"
+ valid.write_bytes(b"0\n")
+ self.assertEqual(star_history.load_star_count_file(valid), 0)
+
+ cases = {
+ "empty": b"",
+ "negative": b"-1\n",
+ "leading-zero": b"01\n",
+ "json": b'{"stargazers_count": 1}\n',
+ "too-large": b"9" * (star_history.MAX_COUNT_FILE_BYTES + 1),
+ "out-of-range": str(star_history.MAX_STAR_COUNT + 1).encode() + b"\n",
+ }
+ for name, payload in cases.items():
+ path = root / name
+ path.write_bytes(payload)
+ with self.subTest(name=name), self.assertRaises(
+ star_history.StarHistoryError
+ ):
+ star_history.load_star_count_file(path)
+
+ link = root / "link"
+ link.symlink_to(valid)
+ with self.assertRaises(star_history.StarHistoryError):
+ star_history.load_star_count_file(link)
+
+ def test_force_same_day_same_count_is_idempotent(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ workspace = Path(temporary)
+ state_path, light_path, dark_path = seed_workspace(
+ workspace,
+ state_with_snapshots(
+ [{"at": "2026-07-20T05:00:00Z", "stars": 100}]
+ ),
+ )
+ before = tuple(path.read_bytes() for path in (state_path, light_path, dark_path))
+
+ result = star_history.execute(
+ "record",
+ github=None,
+ clock=FixedClock("2026-07-20T06:00:00Z"),
+ workspace=workspace,
+ force=True,
+ star_count=100,
+ )
+
+ self.assertFalse(result.changed)
+ self.assertEqual(
+ before,
+ tuple(path.read_bytes() for path in (state_path, light_path, dark_path)),
+ )
+
+ def test_force_same_day_changed_count_replaces_snapshot(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ workspace = Path(temporary)
+ seed_workspace(
+ workspace,
+ state_with_snapshots(
+ [{"at": "2026-07-20T05:00:00Z", "stars": 100}]
+ ),
+ )
+
+ result = star_history.execute(
+ "record",
+ github=None,
+ clock=FixedClock("2026-07-20T06:00:00Z"),
+ workspace=workspace,
+ force=True,
+ star_count=101,
+ )
+
+ self.assertTrue(result.changed)
+ state = star_history.load_state(workspace)
+ self.assertEqual(
+ state["snapshots"],
+ [{"at": "2026-07-20T06:00:00Z", "stars": 101}],
+ )
+
+ def test_new_date_appends_even_when_count_is_unchanged(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ workspace = Path(temporary)
+ seed_workspace(
+ workspace,
+ state_with_snapshots(
+ [{"at": "2026-07-20T05:00:00Z", "stars": 100}]
+ ),
+ )
+
+ result = star_history.execute(
+ "record",
+ github=None,
+ clock=FixedClock("2026-08-04T05:00:00Z"),
+ workspace=workspace,
+ star_count=100,
+ )
+
+ self.assertTrue(result.changed)
+ self.assertEqual(
+ star_history.load_state(workspace)["snapshots"],
+ [
+ {"at": "2026-07-20T05:00:00Z", "stars": 100},
+ {"at": "2026-08-04T05:00:00Z", "stars": 100},
+ ],
+ )
+
+ def test_schema_rejects_unknown_identity_fields_and_boolean_counts(self):
+ baseline = state_with_snapshots()
+ cases = []
+ top = deepcopy(baseline)
+ top["login"] = "secret-user"
+ cases.append(top)
+ reconstruction = deepcopy(baseline)
+ reconstruction["reconstruction"]["avatar"] = "secret-avatar"
+ cases.append(reconstruction)
+ daily = deepcopy(baseline)
+ daily["reconstruction"]["daily"] = [
+ {"date": "2026-06-30", "stars": 1, "user": "secret-user"}
+ ]
+ cases.append(daily)
+ snapshot = deepcopy(baseline)
+ snapshot["snapshots"] = [
+ {"at": "2026-07-20T05:00:00Z", "stars": 1, "profile_url": "secret"}
+ ]
+ cases.append(snapshot)
+ boolean_count = deepcopy(baseline)
+ boolean_count["snapshots"] = [
+ {"at": "2026-07-20T05:00:00Z", "stars": True}
+ ]
+ cases.append(boolean_count)
+
+ for state in cases:
+ with self.subTest(state=state), self.assertRaises(
+ star_history.StarHistoryError
+ ):
+ star_history.validate_state(state)
+
+ def test_svg_is_accessible_self_contained_and_deterministic(self):
+ state = {
+ "schema_version": 1,
+ "repository": "666ghj/MiroFish",
+ "timezone": "UTC",
+ "ongoing_interval_days": 13,
+ "reconstruction": {
+ "method": "current_stargazers_starred_at",
+ "generated_at": "2026-01-04T00:00:00Z",
+ "daily": [
+ {"date": "2026-01-01", "stars": 1},
+ {"date": "2026-01-03", "stars": 3},
+ ],
+ },
+ "snapshots": [
+ {"at": "2026-01-20T10:00:00Z", "stars": 3},
+ {"at": "2026-02-04T10:00:00Z", "stars": 2},
+ ],
+ }
+
+ light = star_history.render_svg(state, "light")
+ dark = star_history.render_svg(state, "dark")
+
+ self.assertEqual(light, star_history.render_svg(state, "light"))
+ self.assertNotEqual(light, dark)
+ self.assertIn(b"viewBox=\"0 0 800 533.333\"", light)
+ self.assertIn(b"Star History", light)
+ self.assertIn(b"666ghj/MiroFish", light)
+ self.assertIn(b"star-history.com", light)
+ self.assertIn(b"feTurbulence", light)
+ self.assertIn(b"feDisplacementMap", light)
+ self.assertIn(b"filter=\"url(#xkcdify)\"", light)
+ self.assertNotIn(b"stroke-dasharray", light)
+ self.assertNotIn(b"'
+ )
+ reviewed_watermark = (
+ ''
+ )
+ valid_shell = (
+ '"
+ )
+ processing_instruction = (
+ ''
+ + valid_shell
+ )
+ unsafe_payloads = [
+ b'',
+ b'',
+ b'',
+ b'',
+ b'',
+ (
+ ''
+ ).encode(),
+ (
+ '"
+ ).encode(),
+ processing_instruction.encode(),
+ processing_instruction.encode("utf-16"),
+ processing_instruction.encode("utf-16-be"),
+ b"\xef\xbb\xbf" + valid_shell.encode(),
+ (
+ ''
+ ).encode(),
+ valid_shell.replace(
+ "",
+ '',
+ ).encode(),
+ valid_shell.replace(
+ "",
+ '',
+ ).encode(),
+ valid_shell.replace(
+ "",
+ '',
+ ).encode(),
+ valid_shell.replace(
+ "",
+ '',
+ ).encode(),
+ ]
+ for payload in unsafe_payloads:
+ with self.subTest(payload=payload), self.assertRaises(
+ star_history.StarHistoryError
+ ):
+ star_history._validate_svg(payload)
+
+ def test_check_detects_svg_tampering(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ workspace = Path(temporary)
+ _, light_path, _ = seed_workspace(workspace, state_with_snapshots())
+ light_path.write_bytes(
+ light_path.read_bytes().replace(b"Star History", b"Star Historx", 1)
+ )
+ with self.assertRaises(star_history.StarHistoryError):
+ star_history.execute(
+ "check",
+ github=None,
+ clock=FixedClock("2026-07-20T05:00:00Z"),
+ workspace=workspace,
+ )
+
+
+class GitHubAdapterTests(unittest.TestCase):
+ def test_graphql_gateway_uses_identity_free_paginated_query(self):
+ payload = {
+ "data": {
+ "repository": {
+ "stargazers": {
+ "totalCount": 1,
+ "edges": [
+ {"cursor": "edge-cursor", "starredAt": "2026-01-01T00:00:00Z"}
+ ],
+ "pageInfo": {"hasNextPage": False, "endCursor": "edge-cursor"},
+ }
+ },
+ "rateLimit": {"cost": 1, "remaining": 4999, "resetAt": "2026-01-01T01:00:00Z"},
+ }
+ }
+ runner = FakeRunner(
+ subprocess.CompletedProcess([], 0, json.dumps(payload), "")
+ )
+ gateway = star_history.GhGraphQLGateway(runner)
+
+ result = gateway.fetch_stargazer_page("previous-page")
+
+ arguments = "\n".join(runner.arguments)
+ self.assertEqual(result.total_count, 1)
+ self.assertIn("first: 100", arguments)
+ self.assertIn("after: $after", arguments)
+ self.assertIn("after=previous-page", arguments)
+ expected_query = """\
+query StarTimes($owner: String!, $name: String!, $after: String) {
+ repository(owner: $owner, name: $name) {
+ stargazers(
+ first: 100
+ after: $after
+ orderBy: {field: STARRED_AT, direction: DESC}
+ ) {
+ totalCount
+ edges { cursor starredAt }
+ pageInfo { hasNextPage endCursor }
+ }
+ }
+ rateLimit { cost remaining resetAt }
+}
+"""
+ self.assertEqual(star_history.GRAPHQL_QUERY, expected_query)
+ for forbidden in (
+ "nodes",
+ " node ",
+ " login ",
+ " avatar ",
+ " databaseId ",
+ " email ",
+ " url ",
+ TOKEN_SENTINEL,
+ ):
+ self.assertNotIn(forbidden, arguments)
+
+ def test_graphql_gateway_rejects_malformed_nested_shape(self):
+ payload = {
+ "data": {
+ "repository": {
+ "stargazers": {
+ "totalCount": 0,
+ "edges": [],
+ "pageInfo": [],
+ }
+ },
+ "rateLimit": {"remaining": 4999},
+ }
+ }
+ gateway = star_history.GhGraphQLGateway(
+ FakeRunner(subprocess.CompletedProcess([], 0, json.dumps(payload), ""))
+ )
+
+ with self.assertRaises(star_history.StarHistoryError):
+ gateway.fetch_stargazer_page(None)
+
+ def test_graphql_gateway_does_not_echo_failed_command_stderr(self):
+ runner = FakeRunner(
+ subprocess.CompletedProcess([], 1, "", f"failure {TOKEN_SENTINEL}")
+ )
+ gateway = star_history.GhGraphQLGateway(runner)
+ with self.assertRaises(star_history.StarHistoryError) as captured:
+ gateway.fetch_stargazer_page(None)
+ self.assertNotIn(TOKEN_SENTINEL, str(captured.exception))
+
+if __name__ == "__main__":
+ unittest.main()