Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .github/workflows/review-suspicious-stars.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Looks again at the auto-discovered pull requests that got the label
# suspicious-stars (many stars on a brand-new repository with few forks and
# commits, see code/discover-apps.sh): 30 days later a genuine project has forks
# in proportion to its stars (at least 10, no more than 30 stars per fork) or a
# real commit history (300 commits). Then code/review-suspicious-stars.sh
# removes suspicious-stars and manual-check-needed and comments once with the
# numbers; a repository that still looks hollow keeps its labels. Monthly, and
# by hand (a dry run by default).

name: Review suspicious stars

on:
schedule:
- cron: '23 5 12 * *' # monthly, a real run (the inputs below are for runs by hand)
workflow_dispatch:
inputs:
days:
description: 'Only pull requests opened at least this many days ago'
type: number
default: 30
limit:
description: 'Clear at most this many pull requests in this run'
type: number
default: 50
dry_run:
description: 'Dry run (only print what would be cleared)'
type: boolean
default: true

jobs:
review:
if: github.repository == 'AppImage/appimage.github.io'
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: review-suspicious-stars
cancel-in-progress: false
permissions:
pull-requests: write
issues: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Review the labeled pull requests
env:
DAYS: ${{ github.event.inputs.days || '30' }}
LIMIT: ${{ github.event.inputs.limit || '50' }}
# A scheduled run is a real run; a run by hand is a dry run unless dry_run is false
DRY_RUN: ${{ github.event_name == 'schedule' && 'false' || github.event.inputs.dry_run }}
run: |
ARGS=(--days "$DAYS" --limit "$LIMIT")
[ "$DRY_RUN" != "false" ] && ARGS+=(--dry-run)
bash code/review-suspicious-stars.sh "${ARGS[@]}"
16 changes: 14 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,8 +288,20 @@ GitHub Pages (Jekyll) from this repository.
a fork whose source publishes no AppImage gets the `not-upstream` label.
A very high star count for a brand-new repository (more than ~15 stars per
day since it was created, at least 300, under ~2 years old) can mean bought
or botted stars, so the PR gets the yellow `manual-check-needed` label and
a note (which also keeps it out of auto-merge).
or botted stars, but genuine projects take off like that too (in October
2026, 61 of 73 PRs flagged by that rule alone were genuine): so the PR is
only flagged when the repository also looks hollow, with fewer than 10
forks or more than 30 stars per fork AND fewer than 300 commits (one
request, the last page of `commits?per_page=1`). Then it gets the labels
`suspicious-stars` (the reason) and `manual-check-needed` (what auto-merge
and the health report look at) and a note with the numbers; a fast-growing
but established repository gets the numbers as a neutral note instead.
`.github/workflows/review-suspicious-stars.yml` (monthly, and
`workflow_dispatch` with `days` (30), `limit` (50) and `dry_run` (default
true); `code/review-suspicious-stars.sh`) looks at the labeled PRs again
once they are 30 days old and removes both labels with one comment (marker
`<!-- appimagehub-stars-review -->`) when forks or commits have caught up;
a repository that still looks hollow keeps them.
The name (file in `data/`, branch
and PR title) is the proper spelling picked by `code/pick-name.sh` from the
repository name, the AppImage's name, the description and the README's
Expand Down
46 changes: 37 additions & 9 deletions code/discover-apps.sh
Original file line number Diff line number Diff line change
Expand Up @@ -735,22 +735,42 @@ while read -r FULLNAME ; do
fi
done

# Very high star count for a brand-new project: a sign of bought or
# botted stars, so a maintainer should look (label manual-check-needed).
# Suspicious when the repository gathered stars implausibly fast: created
# not long ago yet already highly starred (age in days from .created_at).
# Very high star count for a brand-new project: can be a sign of bought or
# botted stars, so a maintainer should look (labels suspicious-stars and
# manual-check-needed). Suspicious when the repository gathered stars
# implausibly fast (created not long ago yet already highly starred) AND
# the rest of the repository does not look like a real project: real
# projects that take off (the press writes about them) have forks in
# proportion to their stars and a commit history, bought stars have
# neither. In October 2026, 61 of 73 PRs flagged by the velocity rule alone
# were genuine (fork ratio and commit count cleared them), so a flag now
# needs stars/forks above 30 (or fewer than 10 forks) and fewer than 300
# commits (one request: the last page number of commits?per_page=1).
SUSPICIOUS_STARS=""
STARS_FACTS=""
CREATED=$(jq -r '.created_at // empty' <<<"$META")
FORKS=$(jq -r '.forks_count // 0' <<<"$META")
[[ "$FORKS" =~ ^[0-9]+$ ]] || FORKS=0
if [[ "$CREATED" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2} ]] && [[ "$STARS" =~ ^[0-9]+$ ]] ; then
CREATED_S=$(date -u -d "${CREATED%%T*}" +%s 2>/dev/null || date -u -j -f %Y-%m-%d "${CREATED%%T*}" +%s 2>/dev/null || echo 0)
if [ "$CREATED_S" -gt 0 ] ; then
AGE_DAYS=$(( ( $(date -u +%s) - CREATED_S ) / 86400 ))
[ "$AGE_DAYS" -lt 1 ] && AGE_DAYS=1
# More than 15 stars per day on average, and young enough that this is
# not just an old popular project (under ~2 years): flag it
# not just an old popular project (under ~2 years)
if [ "$AGE_DAYS" -lt 730 ] && [ "$STARS" -ge $(( AGE_DAYS * 15 )) ] && [ "$STARS" -ge 300 ] ; then
SUSPICIOUS_STARS="$STARS stars for a repository created $AGE_DAYS day(s) ago ($(sanitize "${CREATED%%T*}"))"
echo " suspicious stars? $SUSPICIOUS_STARS"
COMMITS=$(api GET "repos/$OWNER/$RNAME/commits?per_page=1" -D - -o /dev/null 2>/dev/null \
| grep -i '^link:' | grep -o 'page=[0-9]*>; rel="last"' | grep -o '[0-9]*' | head -n 1)
[[ "$COMMITS" =~ ^[0-9]+$ ]] || COMMITS=1
STARS_FACTS="$STARS stars, $FORKS forks, $COMMITS commits, created $AGE_DAYS day(s) ago ($(sanitize "${CREATED%%T*}"))"
# Established after all: forks in proportion (at least 10, and no more
# than 30 stars per fork), or a real commit history
if { [ "$FORKS" -ge 10 ] && [ $(( STARS / FORKS )) -le 30 ] ; } || [ "$COMMITS" -ge 300 ] ; then
echo " fast-growing but established: $STARS_FACTS"
else
SUSPICIOUS_STARS="$STARS_FACTS"
echo " suspicious stars? $SUSPICIOUS_STARS"
fi
fi
fi
fi
Expand All @@ -769,7 +789,11 @@ while read -r FULLNAME ; do
if [ -n "$SUSPICIOUS_STARS" ] ; then
NOTE="$NOTE

**Unusual star count:** $SUSPICIOUS_STARS (label \`manual-check-needed\`). A very high star count on a brand-new project can mean bought or botted stars; please check that the project is genuine before merging."
**Unusual star count:** $SUSPICIOUS_STARS (labels \`suspicious-stars\` and \`manual-check-needed\`). Many stars on a brand-new repository with few forks and commits can mean bought or botted stars; please check that the project is genuine before merging. \`.github/workflows/review-suspicious-stars.yml\` looks at these numbers again after 30 days and removes the labels when they have become healthy."
elif [ -n "$STARS_FACTS" ] ; then
NOTE="$NOTE

**Fast-growing project:** $STARS_FACTS. The forks and commits are in proportion to the stars, so it was not flagged."
fi
if [ -n "$VIA" ] ; then
NOTE="$NOTE
Expand Down Expand Up @@ -855,7 +879,11 @@ discover-apps.yml. Needs a maintainer's review."
API_TOKEN="$PR_TOKEN" api POST "repos/$REPO/labels" -d "$(jq -n '{name:"repackaged", color:"d4c5f9", description:"Built by a third party from someone else'"'"'s application (an unofficial AppImage, repackaged)"}')" >/dev/null
fi
if [ -n "$SUSPICIOUS_STARS" ] ; then
LABELS+=(manual-check-needed)
# suspicious-stars says why; manual-check-needed is what the other
# workflows (auto-merge, health report) look at
LABELS+=(suspicious-stars manual-check-needed)
API_TOKEN="$PR_TOKEN" api GET "repos/$REPO/labels/suspicious-stars" >/dev/null 2>&1 || \
API_TOKEN="$PR_TOKEN" api POST "repos/$REPO/labels" -d "$(jq -n '{name:"suspicious-stars", color:"fbca04", description:"Many stars on a brand-new repository with few forks and commits: possibly bought; review-suspicious-stars.yml re-checks after 30 days"}')" >/dev/null
API_TOKEN="$PR_TOKEN" api GET "repos/$REPO/labels/manual-check-needed" >/dev/null 2>&1 || \
API_TOKEN="$PR_TOKEN" api POST "repos/$REPO/labels" -d "$(jq -n '{name:"manual-check-needed", color:"fbca04", description:"A maintainer should check this before merging"}')" >/dev/null
fi
Expand Down
112 changes: 112 additions & 0 deletions code/review-suspicious-stars.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#!/bin/bash

# Look again at the pull requests that discover-apps.sh labeled
# suspicious-stars (many stars on a brand-new repository with few forks and
# commits), once they are old enough that a genuine project has grown into its
# stars: forks in proportion (at least 10, no more than 30 stars per fork) or a
# real commit history (300 commits). Then the labels suspicious-stars and
# manual-check-needed are removed and one comment gives the numbers; otherwise
# the pull request is left as it is (the comment is never repeated). Used by
# .github/workflows/review-suspicious-stars.yml (monthly, and workflow_dispatch
# with dry_run); runnable by hand.
#
# Usage: review-suspicious-stars.sh [--dry-run] [--days D] [--limit N]
# --days D Only pull requests opened at least D days ago (default 30).
# --limit N Clear at most N pull requests per run (default 50).
#
# Needs GH_TOKEN (and GITHUB_REPOSITORY, default AppImage/appimage.github.io).
# API budget: one request per 100 labeled pull requests, then 2 per candidate
# (repository, commit count) and 3 per cleared one; stops when fewer than
# RESERVE (200) requests of the hour are left.

set -uo pipefail

REPO="${GITHUB_REPOSITORY:-AppImage/appimage.github.io}"
DRY_RUN=false
DAYS=30
LIMIT=50
while [ $# -gt 0 ] ; do
case "$1" in
--dry-run) DRY_RUN=true ;;
--days) DAYS="$2" ; shift ;;
--days=*) DAYS="${1#*=}" ;;
--limit) LIMIT="$2" ; shift ;;
--limit=*) LIMIT="${1#*=}" ;;
*) echo "Unknown argument: $1" >&2 ; exit 2 ;;
esac
shift
done
[[ "$DAYS" =~ ^[0-9]+$ ]] || DAYS=30
[[ "$LIMIT" =~ ^[0-9]+$ ]] && [ "$LIMIT" -ge 1 ] || LIMIT=50
MARKER='<!-- appimagehub-stars-review -->'
L_STARS=suspicious-stars
L_MANUAL=manual-check-needed
WORKDIR=$(mktemp -d)
trap 'rm -rf "$WORKDIR"' EXIT

RESERVE=${RESERVE:-200}
N_REQUESTS=0
api() {
N_REQUESTS=$((N_REQUESTS + 1))
if [ $((N_REQUESTS % 50)) -eq 0 ] ; then
LEFT=$(gh api rate_limit --jq .resources.core.remaining 2>/dev/null || echo 0)
if [ "${LEFT:-0}" -lt "$RESERVE" ] ; then
echo "Only $LEFT API requests left this hour (reserve $RESERVE); stopping after $N_REQUESTS requests" >&2
exit 4
fi
fi
gh api "$@"
}

CUTOFF=$(date -u -d "$DAYS days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-"$DAYS"d +%Y-%m-%dT%H:%M:%SZ)
# Open pull requests with the label, oldest first; the repository is in the
# body ("Repository: https://github.com/OWNER/REPO", written by discover-apps.sh)
api --paginate "repos/$REPO/issues?state=open&labels=$L_STARS&per_page=100&sort=created&direction=asc" \
--jq '.[] | select(.pull_request) | [.number, .created_at, ((.body // "") | capture("Repository: https://github.com/(?<r>[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)").r // "")] | @tsv' \
> "$WORKDIR/prs.tsv" 2>/dev/null || true
echo "$(wc -l < "$WORKDIR/prs.tsv") open pull requests labeled $L_STARS" >&2

N_CLEARED=0 ; N_KEPT=0 ; N_YOUNG=0 ; N_NOREPO=0
: > "$WORKDIR/summary.md"
while IFS=$'\t' read -r N CREATED OR ; do
[ "$N_CLEARED" -lt "$LIMIT" ] || break
if [[ "$CREATED" > "$CUTOFF" ]] ; then N_YOUNG=$((N_YOUNG + 1)) ; continue ; fi
if [ -z "$OR" ] ; then N_NOREPO=$((N_NOREPO + 1)) ; echo "#$N: no repository in the body; left alone" ; continue ; fi
META=$(api "repos/$OR" 2>/dev/null) || { echo "#$N: $OR not readable; left alone" ; N_KEPT=$((N_KEPT + 1)) ; continue ; }
STARS=$(jq -r '.stargazers_count // 0' <<<"$META") ; FORKS=$(jq -r '.forks_count // 0' <<<"$META")
[[ "$STARS" =~ ^[0-9]+$ ]] || STARS=0 ; [[ "$FORKS" =~ ^[0-9]+$ ]] || FORKS=0
COMMITS=$(api -i "repos/$OR/commits?per_page=1" 2>/dev/null | grep -i '^link:' | grep -o 'page=[0-9]*>; rel="last"' | grep -o '[0-9]*' | head -n 1)
[[ "$COMMITS" =~ ^[0-9]+$ ]] || COMMITS=1
FACTS="$STARS stars, $FORKS forks, $COMMITS commits"
if { [ "$FORKS" -ge 10 ] && [ $(( STARS / FORKS )) -le 30 ] ; } || [ "$COMMITS" -ge 300 ] ; then
if [ "$DRY_RUN" = true ] ; then
echo "#$N ($OR): would clear ($FACTS)"
else
{
echo "$MARKER"
echo "The repository was flagged for an unusual star count when this pull request was opened. $DAYS days later it has $FACTS: forks and commits in proportion to the stars, as with a genuine project, so the labels \`$L_STARS\` and \`$L_MANUAL\` are removed. The test result above still decides whether the AppImage can be merged."
} > "$WORKDIR/c.md"
api -X POST "repos/$REPO/issues/$N/comments" -F body=@"$WORKDIR/c.md" >/dev/null
api -X DELETE "repos/$REPO/issues/$N/labels/$L_STARS" >/dev/null 2>&1 || true
api -X DELETE "repos/$REPO/issues/$N/labels/$L_MANUAL" >/dev/null 2>&1 || true
echo "#$N ($OR): cleared ($FACTS)"
fi
echo "- #$N \`$OR\`: cleared ($FACTS)" >> "$WORKDIR/summary.md"
N_CLEARED=$((N_CLEARED + 1))
else
echo "#$N ($OR): still suspicious ($FACTS)"
echo "- #$N \`$OR\`: still suspicious ($FACTS)" >> "$WORKDIR/summary.md"
N_KEPT=$((N_KEPT + 1))
fi
done < "$WORKDIR/prs.tsv"

{
echo "## Suspicious stars, reviewed"
echo
W=$([ "$DRY_RUN" = true ] && echo " (would be)")
echo "Cleared$W: $N_CLEARED; still suspicious: $N_KEPT; younger than $DAYS days: $N_YOUNG; no repository in the body: $N_NOREPO. API requests: $N_REQUESTS."
echo
cat "$WORKDIR/summary.md"
[ "$DRY_RUN" = true ] && echo && echo "(dry run: nothing was changed)"
} | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"
exit 0