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 pull-requests: 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 id: count 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]+$ ]] printf 'value=%s\n' "${lines[0]}" >> "$GITHUB_OUTPUT" - 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 id: verify 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_RUN_ID" =~ ^[0-9]+$ ]] [[ "$GITHUB_RUN_ATTEMPT" =~ ^[0-9]+$ ]] update_branch="automation/star-history/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" [[ "${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 } if git \ -c credential.helper= \ -c http.followRedirects=false \ ls-remote \ --exit-code \ --heads \ origin \ "refs/heads/$update_branch" >/dev/null then echo "::error::Temporary branch already exists" exit 1 else status=$? [[ "$status" -eq 2 ]] || exit "$status" fi printf 'branch=%s\n' "$update_branch" >> "$GITHUB_OUTPUT" printf 'head=%s\n' "$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: Publish through a verified pull request with an ephemeral credential id: publish if: ${{ steps.commit.outputs.created == 'true' }} shell: bash env: GITHUB_TOKEN: ${{ github.token }} UPDATE_BRANCH: ${{ steps.verify.outputs.branch }} EXPECTED_HEAD: ${{ steps.verify.outputs.head }} GIT_TERMINAL_PROMPT: '0' GIT_TRACE: '0' GIT_TRACE_CURL: '0' GIT_TRACE_PACKET: '0' GIT_CURL_VERBOSE: '0' run: | set -euo pipefail umask 077 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" head="$(git rev-parse HEAD)" update_branch="$UPDATE_BRANCH" [[ "$GITHUB_RUN_ID" =~ ^[0-9]+$ ]] [[ "$GITHUB_RUN_ATTEMPT" =~ ^[0-9]+$ ]] [[ "$update_branch" == "automation/star-history/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" ]] [[ "$head" == "$EXPECTED_HEAD" ]] [[ "${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 expected_files="$RUNNER_TEMP/star-history-expected-files-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.txt" pr_files="$RUNNER_TEMP/star-history-pr-files-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.txt" [[ ! -e "$expected_files" && ! -L "$expected_files" ]] [[ ! -e "$pr_files" && ! -L "$pr_files" ]] git diff-tree --no-commit-id --name-only -r HEAD | LC_ALL=C sort > "$expected_files" [[ -s "$expected_files" && ! -L "$expected_files" ]] trap ' rm -f -- "$expected_files" "$pr_files" unset GH_TOKEN GITHUB_TOKEN unset GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0 ' EXIT [[ -n "$GITHUB_TOKEN" ]] [[ "$GITHUB_TOKEN" != *$'\n'* && "$GITHUB_TOKEN" != *$'\r'* ]] command -v gh >/dev/null export GH_TOKEN="$GITHUB_TOKEN" current_base="$( gh api \ "repos/$EXPECTED_REPOSITORY/git/ref/heads/$EXPECTED_DEFAULT_BRANCH" \ --jq '.object.sha' )" [[ "$current_base" == "$base" ]] 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 git \ -c core.hooksPath=/dev/null \ -c credential.helper= \ push \ --porcelain \ origin \ "HEAD:refs/heads/$update_branch" unset GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0 remote_head="$( gh api \ "repos/$EXPECTED_REPOSITORY/git/ref/heads/$update_branch" \ --jq '.object.sha' )" [[ "$remote_head" == "$head" ]] current_base="$( gh api \ "repos/$EXPECTED_REPOSITORY/git/ref/heads/$EXPECTED_DEFAULT_BRANCH" \ --jq '.object.sha' )" [[ "$current_base" == "$base" ]] create_result="$( gh api --method POST "repos/$EXPECTED_REPOSITORY/pulls" \ -f 'title=chore: update Star History' \ -f "head=$update_branch" \ -f "base=$EXPECTED_DEFAULT_BRANCH" \ -f 'body=Automated aggregate Star History refresh. The workflow will verify the base, head, and generated-file allowlist before squash-merging.' \ --jq '[.number, .html_url] | @tsv' )" IFS=$'\t' read -r pr_number pr_url <<< "$create_result" [[ "$pr_number" =~ ^[0-9]+$ ]] [[ "$pr_url" == "https://github.com/$EXPECTED_REPOSITORY/pull/$pr_number" ]] validate_open_pr() { local metadata local pr_state pr_draft pr_base_repo pr_base_ref pr_base_sha local pr_head_repo pr_head_ref pr_head_sha pr_author metadata="$( gh api "repos/$EXPECTED_REPOSITORY/pulls/$pr_number" \ --jq '[.state, (.draft | tostring), .base.repo.full_name, .base.ref, .base.sha, .head.repo.full_name, .head.ref, .head.sha, .user.login] | @tsv' )" IFS=$'\t' read -r \ pr_state pr_draft pr_base_repo pr_base_ref pr_base_sha \ pr_head_repo pr_head_ref pr_head_sha pr_author <<< "$metadata" [[ "$pr_state" == "open" ]] [[ "$pr_draft" == "false" ]] [[ "${pr_base_repo,,}" == "${EXPECTED_REPOSITORY,,}" ]] [[ "$pr_base_ref" == "$EXPECTED_DEFAULT_BRANCH" ]] [[ "$pr_base_sha" == "$base" ]] [[ "${pr_head_repo,,}" == "${EXPECTED_REPOSITORY,,}" ]] [[ "$pr_head_ref" == "$update_branch" ]] [[ "$pr_head_sha" == "$head" ]] [[ "$pr_author" == 'github-actions[bot]' ]] } validate_open_pr gh api \ "repos/$EXPECTED_REPOSITORY/pulls/$pr_number/files?per_page=100" \ --jq '.[].filename' | LC_ALL=C sort > "$pr_files" [[ -s "$pr_files" && ! -L "$pr_files" ]] cmp --silent "$expected_files" "$pr_files" mergeable='null' for attempt in 1 2 3 4 5; do current_base="$( gh api \ "repos/$EXPECTED_REPOSITORY/git/ref/heads/$EXPECTED_DEFAULT_BRANCH" \ --jq '.object.sha' )" [[ "$current_base" == "$base" ]] validate_open_pr gh api \ "repos/$EXPECTED_REPOSITORY/pulls/$pr_number/files?per_page=100" \ --jq '.[].filename' | LC_ALL=C sort > "$pr_files" cmp --silent "$expected_files" "$pr_files" mergeable="$( gh api "repos/$EXPECTED_REPOSITORY/pulls/$pr_number" \ --jq '(.mergeable | tostring)' )" case "$mergeable" in true) break ;; false) echo '::error::Pull request is not mergeable' exit 1 ;; null) if (( attempt < 5 )); then sleep 2 fi ;; *) exit 1 ;; esac done [[ "$mergeable" == 'true' ]] validate_open_pr current_base="$( gh api \ "repos/$EXPECTED_REPOSITORY/git/ref/heads/$EXPECTED_DEFAULT_BRANCH" \ --jq '.object.sha' )" [[ "$current_base" == "$base" ]] merge_result="$( gh api --method PUT "repos/$EXPECTED_REPOSITORY/pulls/$pr_number/merge" \ -f "merge_method=squash" \ -f "sha=$head" \ -f 'commit_title=chore: update star history [skip ci]' \ -f 'commit_message=Automated aggregate Star History refresh.' \ --jq '[(.merged | tostring), .sha] | @tsv' )" IFS=$'\t' read -r merged merge_sha <<< "$merge_result" [[ "$merged" == "true" ]] [[ "$merge_sha" =~ ^[0-9a-f]{40}$ ]] main_sha="$( gh api \ "repos/$EXPECTED_REPOSITORY/git/ref/heads/$EXPECTED_DEFAULT_BRANCH" \ --jq '.object.sha' )" [[ "$main_sha" == "$merge_sha" ]] merge_parent="$( gh api "repos/$EXPECTED_REPOSITORY/git/commits/$merge_sha" \ --jq 'if (.parents | length) == 1 then .parents[0].sha else empty end' )" [[ "$merge_parent" == "$base" ]] merged_metadata="$( gh api "repos/$EXPECTED_REPOSITORY/pulls/$pr_number" \ --jq '[.state, (.merged | tostring), .merge_commit_sha, .base.repo.full_name, .base.ref, .head.ref, .head.sha, .user.login, .merged_by.login] | @tsv' )" IFS=$'\t' read -r \ merged_state pr_merged pr_merge_sha merged_base_repo merged_base_ref \ merged_head_ref merged_head_sha merged_author merged_by <<< "$merged_metadata" [[ "$merged_state" == 'closed' ]] [[ "$pr_merged" == 'true' ]] [[ "$pr_merge_sha" == "$merge_sha" ]] [[ "${merged_base_repo,,}" == "${EXPECTED_REPOSITORY,,}" ]] [[ "$merged_base_ref" == "$EXPECTED_DEFAULT_BRANCH" ]] [[ "$merged_head_ref" == "$update_branch" ]] [[ "$merged_head_sha" == "$head" ]] [[ "$merged_author" == 'github-actions[bot]' ]] [[ "$merged_by" == 'github-actions[bot]' ]] printf 'branch=%s\n' "$update_branch" >> "$GITHUB_OUTPUT" printf 'head_sha=%s\n' "$head" >> "$GITHUB_OUTPUT" printf 'merge_sha=%s\n' "$merge_sha" >> "$GITHUB_OUTPUT" printf 'pr_number=%s\n' "$pr_number" >> "$GITHUB_OUTPUT" printf 'pr_url=%s\n' "$pr_url" >> "$GITHUB_OUTPUT" - name: Verify published main without tokens id: verify_published if: ${{ steps.commit.outputs.created == 'true' }} shell: bash env: GITHUB_TOKEN: '' GH_TOKEN: '' EXPECTED_MERGE_SHA: ${{ steps.publish.outputs.merge_sha }} EXPECTED_STAR_COUNT: ${{ steps.count.outputs.value }} run: | set -euo pipefail [[ -z "${GITHUB_TOKEN:-}" && -z "${GH_TOKEN:-}" ]] expected_merge_sha="$EXPECTED_MERGE_SHA" [[ "$expected_merge_sha" =~ ^[0-9a-f]{40}$ ]] [[ "$EXPECTED_STAR_COUNT" =~ ^[0-9]+$ ]] [[ -z "$(git status --porcelain --untracked-files=all)" ]] git \ -c credential.helper= \ -c http.followRedirects=false \ fetch \ --no-tags \ --depth=2 \ origin \ "refs/heads/$EXPECTED_DEFAULT_BRANCH" published_sha="$(git rev-parse FETCH_HEAD)" [[ "$published_sha" == "$expected_merge_sha" ]] published_parent="$(git rev-parse "$published_sha^")" [[ "$published_parent" == "$GITHUB_SHA" ]] [[ "$(git rev-list --count "${GITHUB_SHA}..$published_sha")" == 1 ]] git diff --quiet HEAD "$published_sha" -- 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 } count=0 while IFS= read -r -d '' path; do allowed "$path" || { printf '::error::Unexpected published path: %q\n' "$path" exit 1 } ((count += 1)) done < <( git diff-tree --no-commit-id --name-only -r -z "$published_sha" ) (( count > 0 )) || exit 1 python3 - "$EXPECTED_STAR_COUNT" <<'PY' import json import sys from pathlib import Path expected = int(sys.argv[1]) payload = json.loads( Path('.github/star-history/history.json').read_text(encoding='utf-8') ) snapshots = payload.get('snapshots') if not isinstance(snapshots, list) or not snapshots: raise SystemExit('missing Star History snapshots') if snapshots[-1].get('stars') != expected: raise SystemExit('published Star count does not match fetched count') PY grep -Fq "cron: '17 3 1,16 * *'" \ .github/workflows/update-star-history.yml printf 'verified=true\n' >> "$GITHUB_OUTPUT" - name: Delete verified temporary branch with an ephemeral credential if: >- ${{ steps.commit.outputs.created == 'true' && steps.verify_published.outputs.verified == 'true' }} shell: bash env: GITHUB_TOKEN: ${{ github.token }} UPDATE_BRANCH: ${{ steps.publish.outputs.branch }} EXPECTED_HEAD: ${{ steps.publish.outputs.head_sha }} EXPECTED_MERGE_SHA: ${{ steps.publish.outputs.merge_sha }} PR_NUMBER: ${{ steps.publish.outputs.pr_number }} run: | set -euo pipefail [[ -n "$GITHUB_TOKEN" ]] [[ "$GITHUB_TOKEN" != *$'\n'* && "$GITHUB_TOKEN" != *$'\r'* ]] command -v gh >/dev/null [[ "$GITHUB_RUN_ID" =~ ^[0-9]+$ ]] [[ "$GITHUB_RUN_ATTEMPT" =~ ^[0-9]+$ ]] update_branch="$UPDATE_BRANCH" [[ "$update_branch" == "automation/star-history/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" ]] [[ "$EXPECTED_HEAD" =~ ^[0-9a-f]{40}$ ]] [[ "$EXPECTED_MERGE_SHA" =~ ^[0-9a-f]{40}$ ]] [[ "$PR_NUMBER" =~ ^[0-9]+$ ]] export GH_TOKEN="$GITHUB_TOKEN" remote_head="$( gh api \ "repos/$EXPECTED_REPOSITORY/git/ref/heads/$update_branch" \ --jq '.object.sha' )" [[ "$remote_head" == "$EXPECTED_HEAD" ]] main_sha="$( gh api \ "repos/$EXPECTED_REPOSITORY/git/ref/heads/$EXPECTED_DEFAULT_BRANCH" \ --jq '.object.sha' )" [[ "$main_sha" == "$EXPECTED_MERGE_SHA" ]] pr_state="$( gh api "repos/$EXPECTED_REPOSITORY/pulls/$PR_NUMBER" \ --jq '[.state, (.merged | tostring), .merge_commit_sha, .head.ref, .head.sha, .user.login, .merged_by.login] | @tsv' )" IFS=$'\t' read -r \ state merged merge_sha head_ref head_sha author merged_by <<< "$pr_state" [[ "$state" == 'closed' ]] [[ "$merged" == 'true' ]] [[ "$merge_sha" == "$EXPECTED_MERGE_SHA" ]] [[ "$head_ref" == "$update_branch" ]] [[ "$head_sha" == "$EXPECTED_HEAD" ]] [[ "$author" == 'github-actions[bot]' ]] [[ "$merged_by" == 'github-actions[bot]' ]] gh api --method DELETE "repos/$EXPECTED_REPOSITORY/git/refs/heads/$update_branch" \ >/dev/null unset GH_TOKEN GITHUB_TOKEN if git \ -c credential.helper= \ -c http.followRedirects=false \ ls-remote \ --exit-code \ --heads \ origin \ "refs/heads/$update_branch" >/dev/null then echo "::error::Temporary branch still exists" exit 1 else status=$? [[ "$status" -eq 2 ]] || exit "$status" fi