diff --git a/.github/workflows/update-star-history.yml b/.github/workflows/update-star-history.yml index 05510c9b..90f5048a 100644 --- a/.github/workflows/update-star-history.yml +++ b/.github/workflows/update-star-history.yml @@ -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 diff --git a/tests/test_local_star_count_fetch.py b/tests/test_local_star_count_fetch.py index b450a50d..13f9f715 100755 --- a/tests/test_local_star_count_fetch.py +++ b/tests/test_local_star_count_fetch.py @@ -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__":