Repository navigation
Publish PR Screenshot #7843
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Posts the result of the "Test" workflow (screenshot on success; screen | |
| # capture, error summary and hints on failure) as a comment on the PR. | |
| # | |
| # This runs via workflow_run because PRs come from forks, whose "Test" runs | |
| # only get a read-only token. Everything read from the artifact is untrusted: | |
| # the PR number is cross-checked against the triggering commit, and text is | |
| # only ever placed inside a code block. | |
| name: Publish PR Screenshot | |
| on: | |
| workflow_run: | |
| workflows: [Test] # Must be the workflow *name*, not its path | |
| types: [completed] | |
| jobs: | |
| publish: | |
| if: > | |
| github.event.workflow_run.event == 'pull_request' && | |
| github.repository == 'AppImage/appimage.github.io' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| actions: write # read the artifact; start the Test run after an auto-merge | |
| contents: write | |
| pull-requests: write | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| REPO: ${{ github.repository }} | |
| RUN_ID: ${{ github.event.workflow_run.id }} | |
| RUN_URL: ${{ github.event.workflow_run.html_url }} | |
| HEAD_SHA: ${{ github.event.workflow_run.head_sha }} | |
| CONCLUSION: ${{ github.event.workflow_run.conclusion }} | |
| steps: | |
| # The default branch, not the PR: only its code/diagnose.sh is trusted | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| with: | |
| sparse-checkout: code | |
| - name: Download test result | |
| run: | | |
| if ! gh run download "$RUN_ID" -R "$REPO" -n pr-result -D out ; then | |
| echo "No pr-result artifact (the test did not get far enough); nothing to post." | |
| exit 0 | |
| fi | |
| ls -laR out | |
| - name: Find the PR | |
| id: pr | |
| run: | | |
| [ -f out/pr-number ] || exit 0 | |
| # workflow_run.pull_requests is always empty for PRs from forks, so | |
| # the number comes from the artifact; verify it really is this commit | |
| PR=$(tr -cd '0-9' < out/pr-number) | |
| [ -n "$PR" ] || exit 0 | |
| PR_SHA=$(gh api "repos/$REPO/pulls/$PR" --jq .head.sha) | |
| if [ "$PR_SHA" != "$HEAD_SHA" ] ; then | |
| echo "PR #$PR head is $PR_SHA, not $HEAD_SHA (newer push?); not posting." | |
| exit 0 | |
| fi | |
| echo "number=$PR" >> "$GITHUB_OUTPUT" | |
| - name: Detect a predominantly Chinese application | |
| id: lang | |
| if: steps.pr.outputs.number != '' | |
| env: | |
| PR: ${{ steps.pr.outputs.number }} | |
| run: | | |
| # If the screenshot or the upstream README is predominantly Chinese, we | |
| # invite the author to offer an English default instead of posting the | |
| # usual test report (see the "Comment on PR" step). | |
| CHINESE=false | |
| # Screenshot: the worker OCRs it (code/check-screenshot.sh) and records | |
| # "chinese" when it is predominantly Chinese. The artifact is untrusted, | |
| # so accept only that exact token. | |
| if [ -f out/language.txt ] && [ "$(tr -cd 'a-z' < out/language.txt)" = chinese ] ; then | |
| CHINESE=true | |
| fi | |
| # README: read from the PR's data/ files at the tested commit via the | |
| # API (trusted, like the @mention logic below), not from the artifact. | |
| if [ "$CHINESE" != true ] ; then | |
| for FILE in $(gh api --paginate "repos/$REPO/pulls/$PR/files" --jq '.[] | select(.filename | startswith("data/")) | select(.status != "removed") | .filename' | head -n 5) ; do | |
| URL=$(gh api -H "Accept: application/vnd.github.raw" "repos/$REPO/contents/$FILE?ref=$HEAD_SHA" 2>/dev/null | head -n 1) | |
| if [ "$(bash code/readme-language.sh "$URL")" = chinese ] ; then CHINESE=true ; break ; fi | |
| done | |
| fi | |
| echo "chinese=$CHINESE" >> "$GITHUB_OUTPUT" | |
| echo "Predominantly Chinese: $CHINESE" | |
| - name: Upload images | |
| if: steps.pr.outputs.number != '' && steps.lang.outputs.chinese != 'true' | |
| env: | |
| PR: ${{ steps.pr.outputs.number }} | |
| REPO_ID: ${{ github.event.repository.id }} | |
| # Classic personal access token with only the public_repo scope, of | |
| # an account with write access to this repository. Optional: without | |
| # it (or when it expires) the ci-screenshots release is used instead. | |
| UPLOAD_TOKEN: ${{ secrets.SCREENSHOT_UPLOAD_TOKEN }} | |
| run: | | |
| # Upload the way pasting an image into a comment does, so GitHub | |
| # hosts it with the comment. This endpoint is undocumented and does | |
| # not accept the workflow token (GITHUB_TOKEN). | |
| upload_attachment() { | |
| [ -n "$UPLOAD_TOKEN" ] || return 1 | |
| curl -sS -o response.json -w '%{http_code}' -X POST \ | |
| "https://uploads.github.com/user-attachments/assets?name=$(basename "$1")&content_type=image/png&repository_id=${REPO_ID}" \ | |
| -H "Authorization: Bearer $UPLOAD_TOKEN" -H "Accept: application/json" \ | |
| --data-binary "@$1" > status.txt || true | |
| URL=$(grep -oE 'https://github\.com/user-attachments/assets/[A-Za-z0-9-]+' response.json | head -n 1) | |
| if [ -z "$URL" ] ; then | |
| echo "::warning::Attachment upload failed (HTTP $(cat status.txt)): $(head -c 300 response.json)" >&2 | |
| return 1 | |
| fi | |
| echo "$URL" | |
| } | |
| upload_release_asset() { | |
| gh release view ci-screenshots -R "$REPO" >/dev/null 2>&1 || \ | |
| gh release create ci-screenshots -R "$REPO" --prerelease \ | |
| -t "CI Screenshots" -n "Automated screenshots from PRs; used in PR comments" >&2 | |
| gh release upload ci-screenshots -R "$REPO" "$1" --clobber >&2 | |
| echo "https://github.com/$REPO/releases/download/ci-screenshots/$(basename "$1")" | |
| } | |
| [ -n "$UPLOAD_TOKEN" ] || echo "SCREENSHOT_UPLOAD_TOKEN is not set, using the ci-screenshots release" | |
| : > images.txt | |
| for f in out/*.png ; do | |
| [ -f "$f" ] || continue | |
| # Only allow safe names, and make sure it is really a PNG | |
| base=$(basename "$f" .png | tr -cd 'A-Za-z0-9._-' | cut -c1-80) | |
| [ "$(file -b --mime-type "$f")" = image/png ] || continue | |
| name="pr-${PR}-${HEAD_SHA::8}-${base}.png" | |
| cp "$f" "$name" | |
| if url=$(upload_attachment "$name") ; then | |
| echo "Uploaded $name as a GitHub attachment" | |
| else | |
| url=$(upload_release_asset "$name") | |
| echo "Uploaded $name to the ci-screenshots release" | |
| fi | |
| echo "$base $url" >> images.txt | |
| done | |
| cat images.txt | |
| - name: Update labels | |
| if: steps.pr.outputs.number != '' | |
| env: | |
| PR: ${{ steps.pr.outputs.number }} | |
| run: | | |
| # error-* labels name the known causes of a failed test, screenshot-ok | |
| # marks a good screenshot; replace the previous ones. Untrusted artifact: only accept labels listed by | |
| # code/diagnose.sh from master | |
| NEW="" | |
| if [ "$CONCLUSION" != success ] && [ -s out/labels.txt ] ; then | |
| NEW=$(grep -xF -f <(bash code/diagnose.sh --list-labels) out/labels.txt | sort -u || true) | |
| fi | |
| # screenshot-ok: the test passed, took a screenshot and found nothing wrong with it | |
| if [ "$CONCLUSION" = success ] && [ -e out/screenshot-ok ] && [ -s images.txt ] ; then | |
| NEW=$(printf '%s\nscreenshot-ok\n' "$NEW" | grep . | sort -u) | |
| fi | |
| OLD=$(gh api "repos/$REPO/issues/$PR/labels" --jq '.[].name | select(startswith("error-") or . == "screenshot-ok")' | sort -u) | |
| for L in $(comm -13 <(echo "$NEW") <(echo "$OLD")) ; do | |
| echo "Removing $L" | |
| gh api -X DELETE "repos/$REPO/issues/$PR/labels/$L" > /dev/null | |
| done | |
| for L in $(comm -23 <(echo "$NEW") <(echo "$OLD")) ; do | |
| echo "Adding $L" | |
| if ! gh api "repos/$REPO/labels/$L" > /dev/null 2>&1 ; then | |
| if [ "$L" = screenshot-ok ] ; then | |
| gh api "repos/$REPO/labels" -f name="$L" -f color=0e8a16 \ | |
| -f description="Test passed; the screenshot was checked and looks fine" > /dev/null | |
| else | |
| gh api "repos/$REPO/labels" -f name="$L" -f color=b60205 \ | |
| -f description="Test failed: see the test result comment" > /dev/null | |
| fi | |
| fi | |
| gh api "repos/$REPO/issues/$PR/labels" -f "labels[]=$L" > /dev/null | |
| done | |
| - name: Prune old release screenshots | |
| if: steps.pr.outputs.number != '' | |
| env: | |
| PR: ${{ steps.pr.outputs.number }} | |
| run: | | |
| # The comment on a PR is updated in place, so screenshots of its | |
| # earlier commits are no longer shown; others expire after 30 days. | |
| # (Releases hold at most 1000 files.) | |
| gh release view ci-screenshots -R "$REPO" >/dev/null 2>&1 || exit 0 | |
| CUTOFF=$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ) | |
| RELEASE_ID=$(gh api "repos/$REPO/releases/tags/ci-screenshots" --jq .id) | |
| gh api --paginate "repos/$REPO/releases/$RELEASE_ID/assets" \ | |
| --jq ".[] | select(.created_at < \"$CUTOFF\" or ((.name | startswith(\"pr-$PR-\")) and (.name | startswith(\"pr-$PR-${HEAD_SHA::8}-\") | not))) | \"\(.id) \(.name)\"" \ | |
| | while read -r id name ; do | |
| echo "Deleting $name" | |
| gh api -X DELETE "repos/$REPO/releases/assets/$id" | |
| done | |
| - name: Comment on PR | |
| if: steps.pr.outputs.number != '' | |
| env: | |
| PR: ${{ steps.pr.outputs.number }} | |
| CHINESE: ${{ steps.lang.outputs.chinese }} | |
| run: | | |
| MARKER='<!-- appimagehub-test-result -->' | |
| # Predominantly Chinese application: instead of the usual test report, | |
| # post a friendly bilingual invitation to offer an English default, so | |
| # the app can be included and reach users internationally. @mention the | |
| # GitHub account the AppImage comes from (unless a bot) so they see it. | |
| if [ "$CHINESE" = true ] ; then | |
| OWNERS=$(gh api --paginate "repos/$REPO/pulls/$PR/files" \ | |
| --jq '.[] | select(.filename | startswith("data/")) | select(.status != "removed") | .filename' \ | |
| | head -n 5 | while read -r FILE ; do | |
| URL=$(gh api -H "Accept: application/vnd.github.raw" "repos/$REPO/contents/$FILE?ref=$HEAD_SHA" 2>/dev/null | head -n 1) | |
| bash code/upstream-owner.sh "$URL" | |
| done | sort -fu) | |
| EARLIER=$(gh api --paginate "repos/$REPO/issues/$PR/comments" \ | |
| --jq ".[] | select(.user.login == \"github-actions[bot]\") | select(.body | startswith(\"$MARKER\")) | .body" || true) | |
| MENTIONS="" | |
| for OWNER in $OWNERS ; do | |
| case "$(echo "$OWNER" | tr 'A-Z' 'a-z')" in *-bot|*\[bot\]) continue ;; esac | |
| grep -qiE "@${OWNER}([^A-Za-z0-9-]|$)" <<<"$EARLIER" && continue | |
| MENTIONS="$MENTIONS @$OWNER" | |
| done | |
| { | |
| echo "$MARKER" | |
| [ -n "$MENTIONS" ] && { echo "${MENTIONS# }" ; echo ; } | |
| cat code/chinese-invite.md | |
| echo | |
| echo "Commit ${HEAD_SHA::8}. [Full log](${RUN_URL})" | |
| } > comment.md | |
| cat comment.md | |
| gh api "repos/$REPO/issues/$PR/comments" -F body=@comment.md >/dev/null | |
| exit 0 | |
| fi | |
| { | |
| echo "$MARKER" | |
| if [ "$CONCLUSION" = success ] ; then | |
| echo "### :white_check_mark: Test passed" | |
| echo | |
| echo "Please check that the screenshot shows the application's main window." | |
| else | |
| echo "### :x: Test ${CONCLUSION}" | |
| fi | |
| echo | |
| while read -r base url ; do | |
| echo "**${base}**" | |
| echo | |
| echo "" | |
| echo | |
| done < images.txt | |
| if [ -s out/libc.txt ] ; then | |
| # Untrusted: only take values of the expected form | |
| val() { grep -xE "X-AppImage-$1=($2)" out/libc.txt | head -n 1 | cut -d = -f 2 || true ; } | |
| LIBC=$(val Libc 'none|bundled|host') | |
| RUNTIME=$(val Runtime 'static|dynamic') | |
| SELF=$(val Self-Contained 'true|false') | |
| GLIBC=$(val Glibc-Required '(GLIBC_)?[0-9]{1,2}(\.[0-9]{1,3}){1,2}' | sed 's/^GLIBC_//') | |
| if [ "$SELF" = true ] && [ -n "$LIBC" ] ; then | |
| case "$LIBC" in | |
| none) echo "**Compatibility:** self-contained (contains no dynamically linked code)" ;; | |
| *) echo "**Compatibility:** self-contained (ships its own C library)" ;; | |
| esac | |
| echo | |
| elif [ -n "$LIBC" ] ; then | |
| REASONS=() | |
| [ "$LIBC" = host ] && REASONS+=("uses the C library of the system") | |
| [ "$RUNTIME" = dynamic ] && REASONS+=("uses an old AppImage runtime that needs the C library (and libfuse2) of the system") | |
| [ -n "$GLIBC" ] && REASONS+=("references glibc $GLIBC") | |
| echo "**Compatibility:** not self-contained: $(IFS=';' ; echo "${REASONS[*]}" | sed 's/;/; /g')" | |
| echo | |
| fi | |
| fi | |
| if [ -s out/names.txt ] ; then | |
| # Untrusted: only lines of the form the test writes, in a code block | |
| NAMES=$(grep -E "^(ERROR|WARNING): (File name|AppImage name) '" out/names.txt | head -n 10 | cut -c1-300 | sed 's/````*/```/g' || true) | |
| if [ -n "$NAMES" ] ; then | |
| echo "**Names:**" | |
| echo | |
| echo '````text' | |
| echo "$NAMES" | |
| echo '````' | |
| echo | |
| fi | |
| fi | |
| if [ -s out/hints.md ] ; then | |
| # Hints are fixed strings from code/diagnose.sh, but the artifact | |
| # is untrusted, so only accept lines we know | |
| HINTS=$(grep -xF -f <(bash code/diagnose.sh --list) out/hints.md || true) | |
| if [ -n "$HINTS" ] ; then | |
| [ "$CONCLUSION" = success ] && echo "**Warnings:**" && echo | |
| echo "$HINTS" | sed 's/^/- /' | |
| echo | |
| fi | |
| fi | |
| if [ "$CONCLUSION" != success ] && [ -s out/error.txt ] ; then | |
| echo "**First error in the log:**" | |
| echo | |
| echo '````text' | |
| # Neutralize anything that could close the code block | |
| head -n 25 out/error.txt | cut -c1-300 | sed 's/````*/```/g' | |
| echo '````' | |
| echo | |
| fi | |
| if [ "$CONCLUSION" != success ] ; then | |
| echo "**What next:** once the AppImage is fixed (e.g. in a new release), comment \`/retest\` here to test it again. If it should not be in the catalog after all, close this pull request; an app that is already in the catalog can be removed by a maintainer with \`/remove\`." | |
| echo | |
| fi | |
| echo "Commit ${HEAD_SHA::8}. [Full log](${RUN_URL})" | |
| } > comment.md | |
| # Errors or findings: @mention the GitHub account the AppImage comes | |
| # from, unless that is who opened the PR (or a bot). The URL is read | |
| # from the PR's data/ files at the tested commit, not from the artifact. | |
| if [ "$CONCLUSION" != success ] || grep -qE '^\*\*(Warnings|Names):\*\*' comment.md ; then | |
| AUTHOR=$(gh api "repos/$REPO/pulls/$PR" --jq '.user.login' | tr 'A-Z' 'a-z') | |
| OWNERS=$(gh api --paginate "repos/$REPO/pulls/$PR/files" \ | |
| --jq '.[] | select(.filename | startswith("data/")) | select(.status != "removed") | .filename' \ | |
| | head -n 5 | while read -r FILE ; do | |
| URL=$(gh api -H "Accept: application/vnd.github.raw" "repos/$REPO/contents/$FILE?ref=$HEAD_SHA" 2>/dev/null | head -n 1) | |
| bash code/upstream-owner.sh "$URL" | |
| done | sort -fu) | |
| EARLIER=$(gh api --paginate "repos/$REPO/issues/$PR/comments" \ | |
| --jq ".[] | select(.user.login == \"github-actions[bot]\") | select(.body | startswith(\"$MARKER\")) | .body" || true) | |
| MENTIONS="" | |
| for OWNER in $OWNERS ; do | |
| LOWER=$(echo "$OWNER" | tr 'A-Z' 'a-z') | |
| [ "$LOWER" = "$AUTHOR" ] && continue | |
| case "$LOWER" in *-bot|*\[bot\]) continue ;; esac | |
| # Every run posts a new comment: mention each account only once | |
| grep -qiE "@${OWNER}([^A-Za-z0-9-]|$)" <<<"$EARLIER" && continue # OWNER: login characters only | |
| MENTIONS="$MENTIONS @$OWNER" | |
| done | |
| if [ -n "$MENTIONS" ] ; then | |
| LINE="${MENTIONS# }: this pull request adds the AppImage from your GitHub repository to the [AppImage catalog](https://appimage.github.io); the test reports the following. Could you have a look?" | |
| # After the heading (line 2 of the comment) | |
| awk -v l="$LINE" 'NR == 3 { print ; print l ; print "" ; next } { print }' comment.md > comment.new && mv comment.new comment.md | |
| fi | |
| fi | |
| cat comment.md | |
| # A new comment for every test run, so that earlier results stay | |
| # readable in the conversation | |
| gh api "repos/$REPO/issues/$PR/comments" -F body=@comment.md >/dev/null | |
| # Merge PRs that need no maintainer: a returning contributor, one file in | |
| # data/, test passed with a good screenshot, same owner of the download | |
| # location. Everything is checked via the API, not the (untrusted) | |
| # artifact. Switch off with the repository variable AUTO_MERGE=false. | |
| - name: Auto-merge | |
| if: steps.pr.outputs.number != '' && vars.AUTO_MERGE != 'false' | |
| env: | |
| PR: ${{ steps.pr.outputs.number }} | |
| CHINESE: ${{ steps.lang.outputs.chinese }} | |
| run: | | |
| skip() { echo "Not auto-merging: $1" ; exit 0 ; } | |
| [ "$CONCLUSION" = success ] || skip "the test did not pass" | |
| [ "$CHINESE" != true ] || skip "predominantly Chinese; invited the author to add an English default" | |
| gh api "repos/$REPO/pulls/$PR" > pr.json | |
| [ "$(jq -r .state pr.json)" = open ] || skip "not open" | |
| [ "$(jq -r .draft pr.json)" = false ] || skip "draft" | |
| [ "$(jq -r .head.sha pr.json)" = "$HEAD_SHA" ] || skip "newer commits than the tested one" | |
| case "$(jq -r .head.ref pr.json)" in discover/*) skip "discovered automatically; a maintainer reviews it" ;; esac | |
| first_line() { gh api -H "Accept: application/vnd.github.raw" "repos/$REPO/contents/$1?ref=$2" | head -n 1 | tr -d '\r' ; } | |
| # Re-test PRs (retest.yml opens them for /retest on a merged PR): | |
| # the entries are listed already, so a passed test is enough, even | |
| # with warnings; but only if they change nothing but existing files | |
| # in data/ and keep each file's first line (the download location) | |
| if [[ "$(jq -r .head.ref pr.json)" == retest/* ]] && [ "$(jq -r .head.repo.full_name pr.json)" = "$REPO" ] ; then | |
| gh api --paginate "repos/$REPO/pulls/$PR/files" --jq '.[] | [.filename, .status] | @tsv' > files.tsv | |
| [ -s files.tsv ] || skip "re-test PR without changes" | |
| while IFS=$'\t' read -r FILE STATUS ; do | |
| [[ "$FILE" == data/* ]] && [ "$STATUS" = modified ] || skip "re-test PR changes $FILE ($STATUS), not only existing files in data/" | |
| [ "$(first_line "$FILE" "$(jq -r .base.sha pr.json)")" = "$(first_line "$FILE" "$HEAD_SHA")" ] || skip "re-test PR changes the download location in $FILE" | |
| done < files.tsv | |
| MERGED=$(gh api -X PUT "repos/$REPO/pulls/$PR/merge" -f sha="$HEAD_SHA" -f merge_method=squash --jq .sha) \ | |
| || skip "GitHub refused the merge" | |
| echo "Merged re-test PR #$PR as $MERGED" | |
| gh workflow run test.yml -R "$REPO" --ref master -f commit="$MERGED" | |
| exit 0 | |
| fi | |
| gh api "repos/$REPO/issues/$PR/labels" --jq '.[].name' | grep -qx auto-discovered && skip "discovered automatically; a maintainer reviews it" | |
| case "$(jq -r .author_association pr.json)" in | |
| CONTRIBUTOR|COLLABORATOR|MEMBER|OWNER) ;; | |
| *) skip "first contribution (author association $(jq -r .author_association pr.json)); a maintainer reviews it" ;; | |
| esac | |
| LABELS=$(gh api "repos/$REPO/issues/$PR/labels" --jq '.[].name') | |
| echo "$LABELS" | grep -qx screenshot-ok || skip "no screenshot-ok label" | |
| ! echo "$LABELS" | grep -qxE 'manual-check-needed|do-not-merge' || skip "labeled for a manual check" | |
| gh api --paginate "repos/$REPO/pulls/$PR/files" --jq '.[] | [.filename, .status] | @tsv' > files.tsv | |
| [ "$(wc -l < files.tsv)" -eq 1 ] || skip "changes more than one file" | |
| IFS=$'\t' read -r FILE STATUS < files.tsv | |
| [[ "$FILE" == data/* ]] || skip "does not change a file in data/" | |
| case "$STATUS" in | |
| added) ;; | |
| modified) | |
| OLD=$(first_line "$FILE" "$(jq -r .base.sha pr.json)") | |
| NEW=$(first_line "$FILE" "$HEAD_SHA") | |
| bash code/check-origin.sh "$OLD" "$NEW" || skip "the download location changes owner" | |
| ;; | |
| *) skip "$STATUS file" ;; | |
| esac | |
| MERGED=$(gh api -X PUT "repos/$REPO/pulls/$PR/merge" -f sha="$HEAD_SHA" -f merge_method=squash --jq .sha) \ | |
| || skip "GitHub refused the merge" | |
| echo "Merged PR #$PR as $MERGED" | |
| # A merge with this workflow's token does not trigger the push run that writes database/ | |
| gh workflow run test.yml -R "$REPO" --ref master -f commit="$MERGED" |