feat: localize star history

This commit is contained in:
666ghj 2026-07-21 15:46:53 +08:00
parent 96096ea0ff
commit 4ebb2ed6e9
11 changed files with 4057 additions and 9 deletions

View File

@ -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.

866
.github/star-history/history.json vendored Normal file
View File

@ -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"
}

View File

@ -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"

View File

@ -194,10 +194,10 @@ MiroFish 的仿真引擎由 **[OASIS](https://github.com/camel-ai/oasis)** 驱
## 📈 项目统计
<a href="https://www.star-history.com/#666ghj/MiroFish&type=date&legend=top-left">
<a href="https://github.com/666ghj/MiroFish">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&legend=top-left" />
<source media="(prefers-color-scheme: dark)" srcset="static/image/star-history-dark.svg" />
<source media="(prefers-color-scheme: light)" srcset="static/image/star-history-light.svg" />
<img alt="666ghj/MiroFish Star History Chart" src="static/image/star-history-light.svg" />
</picture>
</a>

View File

@ -194,10 +194,10 @@ MiroFish's simulation engine is powered by **[OASIS (Open Agent Social Interacti
## 📈 Project Statistics
<a href="https://www.star-history.com/#666ghj/MiroFish&type=date&legend=top-left">
<a href="https://github.com/666ghj/MiroFish">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&legend=top-left" />
<source media="(prefers-color-scheme: dark)" srcset="static/image/star-history-dark.svg" />
<source media="(prefers-color-scheme: light)" srcset="static/image/star-history-light.svg" />
<img alt="666ghj/MiroFish Star History Chart" src="static/image/star-history-light.svg" />
</picture>
</a>
</a>

146
scripts/fetch_star_count.py Executable file
View File

@ -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())

1489
scripts/star_history.py Executable file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -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()

954
tests/test_local_star_history.py Executable file
View File

@ -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"<polyline", light)
self.assertNotIn(TOKEN_SENTINEL.encode(), light)
root = ET.fromstring(light)
names = {element.tag.rsplit("}", 1)[-1] for element in root.iter()}
self.assertIn("title", names)
self.assertIn("desc", names)
self.assertIn("filter", names)
self.assertIn("image", names)
self.assertTrue(names.isdisjoint({"script", "foreignObject"}))
images = [
element
for element in root.iter()
if element.tag.rsplit("}", 1)[-1] == "image"
]
self.assertEqual(len(images), 2)
images_by_href = {element.attrib["href"]: element for element in images}
avatar_image = images_by_href[star_history.OWNER_AVATAR_DATA_URI]
watermark_image = images_by_href[star_history.WATERMARK_LOGO_DATA_URI]
self.assertEqual(
avatar_image.attrib["href"], star_history.OWNER_AVATAR_DATA_URI
)
self.assertEqual(avatar_image.attrib["width"], "22")
self.assertEqual(avatar_image.attrib["height"], "22")
avatar = base64.b64decode(
star_history.OWNER_AVATAR_BASE64, validate=True
)
self.assertLessEqual(len(avatar), star_history.MAX_INLINE_AVATAR_BYTES)
self.assertEqual(
hashlib.sha256(avatar).hexdigest(),
star_history.OWNER_AVATAR_SHA256,
)
self.assertEqual(
star_history._reviewed_avatar_dimensions(avatar),
star_history.OWNER_AVATAR_DIMENSIONS,
)
watermark = base64.b64decode(
star_history.WATERMARK_LOGO_BASE64, validate=True
)
self.assertEqual(len(watermark), 4_185)
self.assertEqual(
hashlib.sha256(watermark).hexdigest(),
star_history.WATERMARK_LOGO_SHA256,
)
self.assertEqual(
(
int.from_bytes(watermark[16:20], "big"),
int.from_bytes(watermark[20:24], "big"),
),
star_history.WATERMARK_LOGO_DIMENSIONS,
)
self.assertEqual(watermark_image.attrib["width"], "20")
self.assertEqual(watermark_image.attrib["height"], "20")
curves = [
element
for element in root.iter()
if element.attrib.get("class") == "xkcd-chart-xyline"
]
self.assertEqual(len(curves), 1)
self.assertFalse(curves[0].attrib["d"].startswith("M70,483.33"))
self.assertIn("C", curves[0].attrib["d"])
visible_labels = [
element.text or ""
for element in root.iter()
if element.tag.rsplit("}", 1)[-1] == "text"
]
self.assertFalse(
any(
word in label.lower()
for label in visible_labels
for word in ("reconstructed", "snapshot")
)
)
legend_labels = [
element.text
for element in root.iter()
if element.tag.rsplit("}", 1)[-1] == "text"
and element.text == "666ghj/MiroFish"
]
self.assertEqual(legend_labels, ["666ghj/MiroFish"])
for element in root.iter():
for name, value in element.attrib.items():
local = name.rsplit("}", 1)[-1].lower()
self.assertFalse(local.startswith("on"))
self.assertNotEqual(local, "src")
if local == "href":
self.assertIn(
value,
{
star_history.OWNER_AVATAR_DATA_URI,
star_history.WATERMARK_LOGO_DATA_URI,
},
)
self.assertFalse(value.lower().startswith(("http:", "https:", "//")))
single_point = star_history.render_svg(
state_with_snapshots(
[{"at": "2026-07-20T05:00:00Z", "stars": 42}]
),
"light",
)
single_root = ET.fromstring(single_point)
single_curve = next(
element
for element in single_root.iter()
if element.attrib.get("class") == "xkcd-chart-xyline"
)
self.assertEqual(single_curve.attrib["d"], "M416,60H424")
self.assertNotIn("483.33", single_curve.attrib["d"])
self.assertNotIn("L", single_curve.attrib["d"])
self.assertNotIn("C", single_curve.attrib["d"])
single_text = [
element.text
for element in single_root.iter()
if element.tag.rsplit("}", 1)[-1] == "text"
]
self.assertEqual(
[
label
for label in single_text
if label in {"Sun 19", "Mon 20", "Tue 21"}
],
["Sun 19", "Mon 20", "Tue 21"],
)
medium_window = state_with_snapshots(
[
{"at": "2026-01-01T00:00:00Z", "stars": 1},
{"at": "2026-04-01T00:00:00Z", "stars": 2},
]
)
medium_window["reconstruction"]["generated_at"] = "2026-01-01T00:00:00Z"
medium_text = [
element.text or ""
for element in ET.fromstring(
star_history.render_svg(medium_window, "light")
).iter()
if element.tag.rsplit("}", 1)[-1] == "text"
]
date_ticks = [
label
for label in medium_text
if len(label.split()) == 2
and label.split()[0].isdigit()
and len(label.split()[1]) == 3
]
self.assertEqual(len(date_ticks), 6)
self.assertEqual(len(set(date_ticks)), 6)
short_window = {
"schema_version": 1,
"repository": "666ghj/MiroFish",
"timezone": "UTC",
"ongoing_interval_days": 13,
"reconstruction": {
"method": "current_stargazers_starred_at",
"generated_at": "2026-01-02T00:00:00Z",
"daily": [{"date": "2026-01-01", "stars": 1}],
},
"snapshots": [{"at": "2026-01-02T05:00:00Z", "stars": 2}],
}
short_text = [
element.text or ""
for element in ET.fromstring(
star_history.render_svg(short_window, "light")
).iter()
if element.tag.rsplit("}", 1)[-1] == "text"
]
weekday_ticks = [
label
for label in short_text
if label.split()[0]
in {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
]
self.assertEqual(weekday_ticks, ["Fri 02"])
earliest_state = state_with_snapshots()
earliest_state["reconstruction"]["generated_at"] = "0001-01-01T00:00:00Z"
self.assertIn(
b"Star History", star_history.render_svg(earliest_state, "light")
)
empty_root = ET.fromstring(
star_history.render_svg(state_with_snapshots(), "light")
)
empty_y_ticks = [
float(element.attrib["y1"])
for element in empty_root.iter()
if element.tag.rsplit("}", 1)[-1] == "line"
and element.attrib.get("x1") == "69"
and element.attrib.get("x2") == "70"
]
self.assertEqual(len(empty_y_ticks), 5)
self.assertTrue(all(60 <= value <= 483.333 for value in empty_y_ticks))
def test_svg_validator_rejects_unreviewed_or_active_content(self):
reviewed_avatar = (
'<image x="316" y="12" width="22" height="22" href="'
+ star_history.OWNER_AVATAR_DATA_URI
+ '" clip-path="url(#clip-circle-title)"/>'
)
reviewed_watermark = (
'<image x="635" y="508.333" width="20" height="20" href="'
+ star_history.WATERMARK_LOGO_DATA_URI
+ '"/>'
)
valid_shell = (
'<svg xmlns="http://www.w3.org/2000/svg">'
+ reviewed_avatar
+ reviewed_watermark
+ "</svg>"
)
processing_instruction = (
'<?xml-stylesheet href="https://example.invalid/x.css"?>'
+ valid_shell
)
unsafe_payloads = [
b'<svg><image href="https://example.invalid/avatar.jpg"/></svg>',
b'<svg><image href="data:image/jpeg;base64,/9j/2Q=="/></svg>',
b'<svg onload="alert(1)"></svg>',
b'<svg><script>unsafe</script></svg>',
b'<svg><path filter="url(https://example.invalid/filter.svg)"/></svg>',
(
'<svg xmlns="http://www.w3.org/2000/svg">'
+ reviewed_avatar
+ '<style>@import url(https://example.invalid/x.css)</style></svg>'
).encode(),
(
'<svg xmlns="http://www.w3.org/2000/svg" '
'style="background:url(https://example.invalid/x.png)">'
+ reviewed_avatar
+ "</svg>"
).encode(),
processing_instruction.encode(),
processing_instruction.encode("utf-16"),
processing_instruction.encode("utf-16-be"),
b"\xef\xbb\xbf" + valid_shell.encode(),
(
'<svg xmlns="http://www.w3.org/2000/svg">'
'<image href="'
+ star_history.OWNER_AVATAR_DATA_URI
+ '"/></svg>'
).encode(),
valid_shell.replace(
"</svg>",
'<path filter="u\\72l(https://example.invalid/f.svg)"/></svg>',
).encode(),
valid_shell.replace(
"</svg>",
'<path filter="u/**/rl(https://example.invalid/f.svg)"/></svg>',
).encode(),
valid_shell.replace(
"</svg>",
'<path fill="u\\000072l(https://example.invalid/f.svg)"/></svg>',
).encode(),
valid_shell.replace(
"</svg>",
'<path filter="URL(HTTPS://example.invalid/f.svg)"/></svg>',
).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()