fix: submit Star History updates as pull requests (#754)

The workflow now creates and verifies a pull request for manual owner merge without attempting to update main itself.
This commit is contained in:
BaiFu 2026-08-03 18:12:17 +08:00 committed by GitHub
parent a97ba4ffc9
commit 0f67b29c6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 84 additions and 269 deletions

View File

@ -282,11 +282,8 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
UPDATE_BRANCH: ${{ steps.verify.outputs.branch }}
EXPECTED_HEAD: ${{ steps.verify.outputs.head }}
EXPECTED_STAR_COUNT: ${{ steps.count.outputs.value }}
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
@ -307,6 +304,7 @@ jobs:
[[ "$GITHUB_RUN_ATTEMPT" =~ ^[0-9]+$ ]]
[[ "$update_branch" == "automation/star-history/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" ]]
[[ "$head" == "$EXPECTED_HEAD" ]]
[[ "$EXPECTED_STAR_COUNT" =~ ^[0-9]+$ ]]
[[ "${GITHUB_REPOSITORY,,}" == "${EXPECTED_REPOSITORY,,}" ]]
[[ "$GITHUB_REF" == "refs/heads/$EXPECTED_DEFAULT_BRANCH" ]]
[[ "$(git rev-parse HEAD^)" == "$base" ]]
@ -338,13 +336,17 @@ jobs:
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"
open_prs="$RUNNER_TEMP/star-history-open-prs-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.txt"
body_file="$RUNNER_TEMP/star-history-pr-body-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.md"
[[ ! -e "$expected_files" && ! -L "$expected_files" ]]
[[ ! -e "$pr_files" && ! -L "$pr_files" ]]
[[ ! -e "$open_prs" && ! -L "$open_prs" ]]
[[ ! -e "$body_file" && ! -L "$body_file" ]]
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"
rm -f -- "$expected_files" "$pr_files" "$open_prs" "$body_file"
unset GH_TOKEN GITHUB_TOKEN
unset GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0
' EXIT
@ -354,6 +356,23 @@ jobs:
command -v gh >/dev/null
export GH_TOKEN="$GITHUB_TOKEN"
gh api --paginate \
"repos/$EXPECTED_REPOSITORY/pulls?state=open&base=$EXPECTED_DEFAULT_BRANCH&per_page=100" \
--jq '.[] | [.number, .user.login, .head.repo.full_name, .head.ref] | @tsv' \
> "$open_prs"
[[ -f "$open_prs" && ! -L "$open_prs" ]]
while IFS=$'\t' read -r existing_number existing_author existing_repo existing_head; do
if [[ \
"$existing_number" =~ ^[0-9]+$ &&
"$existing_author" == 'github-actions[bot]' &&
"${existing_repo,,}" == "${EXPECTED_REPOSITORY,,}" &&
"$existing_head" == automation/star-history/*
]]; then
echo "::error::An existing automated Star History pull request is still open: #$existing_number"
exit 1
fi
done < "$open_prs"
current_base="$(
gh api \
"repos/$EXPECTED_REPOSITORY/git/ref/heads/$EXPECTED_DEFAULT_BRANCH" \
@ -395,12 +414,24 @@ jobs:
)"
[[ "$current_base" == "$base" ]]
{
echo 'Automated aggregate Star History refresh.'
echo
echo 'Please review and merge this pull request manually.'
echo
printf -- '- Triggering main SHA: `%s`\n' "$base"
printf -- '- Generated commit SHA: `%s`\n' "$head"
printf -- '- Recorded Star count: `%s`\n' "$EXPECTED_STAR_COUNT"
echo '- Allowed files: history JSON plus light and dark SVGs only.'
} > "$body_file"
[[ -s "$body_file" && ! -L "$body_file" ]]
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.' \
-f "body=$(<"$body_file")" \
--jq '[.number, .html_url] | @tsv'
)"
IFS=$'\t' read -r pr_number pr_url <<< "$create_result"
@ -438,40 +469,14 @@ jobs:
[[ -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
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" \
@ -479,193 +484,17 @@ jobs:
)"
[[ "$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]' ]]
{
echo '### Star History pull request'
echo
printf -- '- Pull request: [#%s](%s)\n' "$pr_number" "$pr_url"
printf -- '- Base SHA: `%s`\n' "$base"
printf -- '- Head SHA: `%s`\n' "$head"
printf -- '- Recorded Star count: `%s`\n' "$EXPECTED_STAR_COUNT"
echo '- Merge: manual owner review required'
} >> "$GITHUB_STEP_SUMMARY"
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

View File

@ -223,7 +223,7 @@ class FetchStarCountTests(unittest.TestCase):
for section in workflow.split("\n - name: ")
if "GITHUB_TOKEN: ${{ github.token }}" in section
]
self.assertEqual(len(token_steps), 3)
self.assertEqual(len(token_steps), 2)
for section in token_steps:
step_name = section.splitlines()[0]
self.assertTrue(
@ -231,9 +231,6 @@ class FetchStarCountTests(unittest.TestCase):
or step_name.startswith(
"Publish through a verified pull request with an ephemeral credential"
)
or step_name.startswith(
"Delete verified temporary branch with an ephemeral credential"
)
)
record_steps = [
@ -248,7 +245,7 @@ class FetchStarCountTests(unittest.TestCase):
self.assertNotIn("${{ github.token }}", section)
self.assertIn("--force", section)
def test_workflow_publishes_only_through_a_verified_pull_request(self):
def test_workflow_submits_a_verified_pull_request_for_manual_merge(self):
repository = Path(__file__).resolve().parents[1]
workflow = (
repository / ".github/workflows/update-star-history.yml"
@ -289,56 +286,45 @@ class FetchStarCountTests(unittest.TestCase):
self.assertIn('[[ "$pr_head_ref" == "$update_branch" ]]', workflow)
self.assertIn('[[ "$pr_head_sha" == "$head" ]]', workflow)
self.assertIn('cmp --silent "$expected_files" "$pr_files"', workflow)
self.assertGreaterEqual(workflow.count('validate_open_pr\n'), 2)
self.assertGreaterEqual(
workflow.count('[[ "$current_base" == "$base" ]]'), 3
)
self.assertIn("for attempt in 1 2 3 4 5; do", workflow)
self.assertIn("(.mergeable | tostring)", workflow)
self.assertIn("sleep 2", workflow)
self.assertGreaterEqual(workflow.count(" validate_open_pr\n"), 2)
self.assertIn("Please review and merge this pull request manually.", workflow)
self.assertIn("$GITHUB_STEP_SUMMARY", workflow)
self.assertIn("existing automated Star History pull request", workflow)
self.assertNotIn("pulls/$pr_number/merge", workflow)
self.assertNotIn("gh pr merge", workflow)
self.assertNotIn("merge_method=", workflow)
self.assertNotIn("pulls/$pr_number/reviews", workflow)
self.assertNotIn("git/refs/heads/$update_branch", workflow)
self.assertNotIn("Verify published main without tokens", workflow)
self.assertNotIn("Delete verified temporary branch", workflow)
self.assertNotIn("merged_by", workflow)
self.assertNotIn("GIT_TRACE:", workflow)
self.assertNotIn("GIT_TRACE_CURL:", workflow)
self.assertNotIn("GIT_TRACE_PACKET:", workflow)
self.assertNotIn("GIT_CURL_VERBOSE:", workflow)
self.assertIn(
'gh api --method PUT "repos/$EXPECTED_REPOSITORY/pulls/$pr_number/merge"',
workflow,
duplicate_guard_index = workflow.index(
"pulls?state=open&base=$EXPECTED_DEFAULT_BRANCH&per_page=100"
)
self.assertIn('-f "merge_method=squash"', workflow)
self.assertIn('-f "sha=$head"', workflow)
self.assertIn('[[ "$current_base" == "$base" ]]', workflow)
self.assertIn('[[ "$merged" == "true" ]]', workflow)
self.assertIn('[[ "$main_sha" == "$merge_sha" ]]', workflow)
self.assertIn('[[ "$merge_parent" == "$base" ]]', workflow)
self.assertGreaterEqual(
workflow.count('[[ "$merged_by" == \'github-actions[bot]\' ]]'), 2
)
self.assertIn(
'gh api --method DELETE "repos/$EXPECTED_REPOSITORY/git/refs/heads/$update_branch"',
workflow,
)
self.assertIn("Verify published main without tokens", workflow)
self.assertIn('[[ "$published_sha" == "$expected_merge_sha" ]]', workflow)
self.assertIn('[[ "$published_parent" == "$GITHUB_SHA" ]]', workflow)
self.assertIn('git diff --quiet HEAD "$published_sha"', workflow)
self.assertIn("published Star count does not match fetched count", workflow)
self.assertIn("grep -Fq \"cron: '17 3 1,16 * *'\"", workflow)
push_index = workflow.index('"HEAD:refs/heads/$update_branch"')
create_index = workflow.index(
'gh api --method POST "repos/$EXPECTED_REPOSITORY/pulls"'
)
files_index = workflow.index(
'"repos/$EXPECTED_REPOSITORY/pulls/$pr_number/files?per_page=100"'
)
merge_index = workflow.index(
'gh api --method PUT "repos/$EXPECTED_REPOSITORY/pulls/$pr_number/merge"'
)
verify_index = workflow.index("- name: Verify published main without tokens")
delete_index = workflow.index(
'gh api --method DELETE "repos/$EXPECTED_REPOSITORY/git/refs/heads/$update_branch"'
revalidate_index = workflow.rindex(" validate_open_pr\n")
remote_head_index = workflow.rindex(
'"repos/$EXPECTED_REPOSITORY/git/ref/heads/$update_branch"'
)
summary_index = workflow.index("$GITHUB_STEP_SUMMARY")
self.assertLess(duplicate_guard_index, push_index)
self.assertLess(push_index, create_index)
self.assertLess(create_index, files_index)
self.assertLess(files_index, merge_index)
self.assertLess(merge_index, verify_index)
self.assertLess(verify_index, delete_index)
self.assertLess(files_index, summary_index)
self.assertLess(files_index, revalidate_index)
self.assertLess(revalidate_index, remote_head_index)
self.assertLess(remote_head_index, summary_index)
if __name__ == "__main__":