| name: PR Artifacts |
|
|
| on: |
| workflow_dispatch: |
| pull_request_target: |
| types: [opened, synchronize, reopened, closed] |
| branches: |
| - main |
| pull_request_review: |
| types: [submitted] |
|
|
| jobs: |
| cleanup-on-approval: |
| if: github.event_name == 'pull_request_review' && github.event.review.state == 'approved' |
| runs-on: ubuntu-latest |
| concurrency: |
| group: pr-artifacts-live-e2e-${{ github.event.pull_request.number }} |
| cancel-in-progress: false |
| permissions: |
| contents: write |
| issues: write |
| pull-requests: write |
| steps: |
| - name: Check if fork PR |
| id: check_fork |
| run: | |
| if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.event.pull_request.base.repo.full_name }}" ]; then |
| echo "is_fork=true" >> "$GITHUB_OUTPUT" |
| echo "::notice::Fork PR detected - skipping automatic .pr cleanup." |
| else |
| echo "is_fork=false" >> "$GITHUB_OUTPUT" |
| fi |
| |
| - name: Resolve PR context |
| id: pr_context |
| if: steps.check_fork.outputs.is_fork == 'false' |
| env: |
| GH_TOKEN: ${{ github.token }} |
| run: | |
| set -euo pipefail |
| pr_api="repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" |
| head_ref="$(gh api "$pr_api" --jq '.head.ref')" |
| head_sha="$(gh api "$pr_api" --jq '.head.sha')" |
| echo "head_ref=$head_ref" >> "$GITHUB_OUTPUT" |
| echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT" |
| if gh api "repos/${{ github.repository }}/contents/.pr?ref=$head_sha" --silent >/dev/null 2>&1; then |
| echo "has_pr_dir=true" >> "$GITHUB_OUTPUT" |
| else |
| echo "has_pr_dir=false" >> "$GITHUB_OUTPUT" |
| echo "::notice::No .pr directory exists at the approved PR head; skipping artifact cleanup." |
| fi |
| |
| - name: Check out PR branch |
| if: steps.check_fork.outputs.is_fork == 'false' && steps.pr_context.outputs.has_pr_dir == 'true' |
| env: |
| PR_ARTIFACT_PUSH_TOKEN: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }} |
| PR_HEAD_SHA: ${{ steps.pr_context.outputs.head_sha }} |
| run: | |
| set -euo pipefail |
| git init . |
| git remote add origin "https://github.com/${GITHUB_REPOSITORY}.git" |
| git fetch origin "$PR_HEAD_SHA" |
| git checkout --detach FETCH_HEAD |
| actual_sha="$(git rev-parse HEAD)" |
| if [ "$actual_sha" != "$PR_HEAD_SHA" ]; then |
| echo "::error::SHA mismatch: expected $PR_HEAD_SHA, got $actual_sha" |
| exit 1 |
| fi |
| git config credential.helper '!f() { echo "username=x-access-token"; echo "password=$PR_ARTIFACT_PUSH_TOKEN"; }; f' |
| |
| - name: Remove .pr directory |
| id: remove |
| if: steps.check_fork.outputs.is_fork == 'false' && steps.pr_context.outputs.has_pr_dir == 'true' |
| env: |
| PR_ARTIFACT_PUSH_TOKEN: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }} |
| PR_HEAD_REF: ${{ steps.pr_context.outputs.head_ref }} |
| PR_HEAD_SHA: ${{ steps.pr_context.outputs.head_sha }} |
| run: | |
| set -euo pipefail |
| if [ ! -d ".pr" ]; then |
| echo "removed=false" >> "$GITHUB_OUTPUT" |
| echo "::notice::No .pr directory to remove." |
| exit 0 |
| fi |
| |
| git config user.name "allhands-bot" |
| git config user.email "allhands-bot@users.noreply.github.com" |
| git rm -rf --ignore-unmatch .pr/ |
| if git diff --cached --quiet; then |
| echo "removed=false" >> "$GITHUB_OUTPUT" |
| echo "::notice::No tracked .pr files to remove." |
| exit 0 |
| fi |
| git commit -m "chore: Remove PR-only artifacts" |
| push_succeeded=false |
| for attempt in 1 2 3; do |
| if git push origin "HEAD:refs/heads/$PR_HEAD_REF"; then |
| push_succeeded=true |
| break |
| fi |
|
|
| if [ "$attempt" -lt 3 ]; then |
| echo "::notice::Failed to push cleanup commit, rebasing and retrying." |
| git fetch origin "$PR_HEAD_REF" |
| if ! git rebase FETCH_HEAD; then |
| echo "::error::Failed to rebase .pr cleanup commit. Manual resolution required." |
| exit 1 |
| fi |
| if ! git merge-base --is-ancestor "$PR_HEAD_SHA" HEAD; then |
| echo "::error::Rebased .pr cleanup commit no longer descends from the approved PR head $PR_HEAD_SHA." |
| exit 1 |
| fi |
| sleep 2 |
| fi |
| done |
| if [ "$push_succeeded" != "true" ]; then |
| echo "::error::Failed to push .pr cleanup commit after retries." |
| exit 1 |
| fi |
| echo "removed=true" >> "$GITHUB_OUTPUT" |
|
|
| - name: Update PR artifacts comment |
| if: steps.check_fork.outputs.is_fork == 'false' && steps.pr_context.outputs.has_pr_dir == 'true' && steps.remove.outputs.removed == 'true' |
| uses: actions/github-script@v9 |
| with: |
| script: | |
| const marker = '<!-- pr-artifacts-notice -->'; |
| const body = [ |
| marker, |
| '**PR Artifacts Cleaned Up**', |
| '', |
| 'The `.pr/` directory has been removed after approval.', |
| '', |
| ].join('\n'); |
| |
| const { data: comments } = await github.rest.issues.listComments({ |
| owner: context.repo.owner, |
| repo: context.repo.repo, |
| issue_number: context.issue.number, |
| }); |
| const existing = comments.find((comment) => comment.body.includes(marker)); |
|
|
| if (existing) { |
| await github.rest.issues.updateComment({ |
| owner: context.repo.owner, |
| repo: context.repo.repo, |
| comment_id: existing.id, |
| body, |
| }); |
| } |
|
|
| check-pr-artifacts: |
| if: github.event_name == 'pull_request_target' && github.event.action != 'closed' |
| runs-on: ubuntu-latest |
| permissions: |
| contents: read |
| issues: write |
| pull-requests: write |
| steps: |
| - name: Post or update PR artifacts comment |
| uses: actions/github-script@v9 |
| with: |
| script: | |
| const marker = '<!-- pr-artifacts-notice -->'; |
| const pullRequest = context.payload.pull_request; |
| const headRepository = pullRequest.head.repo; |
| let exists = true; |
| |
| try { |
| await github.rest.repos.getContent({ |
| owner: headRepository.owner.login, |
| repo: headRepository.name, |
| path: '.pr', |
| ref: pullRequest.head.sha, |
| }); |
| } catch (error) { |
| if (error.status === 404) { |
| exists = false; |
| } else { |
| throw error; |
| } |
| } |
|
|
| const comments = await github.paginate( |
| github.rest.issues.listComments, |
| { |
| owner: context.repo.owner, |
| repo: context.repo.repo, |
| issue_number: context.issue.number, |
| per_page: 100, |
| }, |
| ); |
| const existing = comments.find((comment) => comment.body.includes(marker)); |
|
|
| if (!exists) { |
| if (existing) { |
| await github.rest.issues.updateComment({ |
| owner: context.repo.owner, |
| repo: context.repo.repo, |
| comment_id: existing.id, |
| body: [ |
| marker, |
| '**PR Artifacts Cleaned Up**', |
| '', |
| 'The `.pr/` directory is no longer present.', |
| '', |
| ].join('\n'), |
| }); |
| } |
| return; |
| } |
|
|
| const isFork = headRepository.full_name !== context.payload.repository.full_name; |
| const cleanup = isFork |
| ? 'Because this is a fork PR, the workflow will **open or update a cleanup PR against `main` after merge**.' |
| : 'The directory will be **automatically removed when the PR is approved**.'; |
| const body = [ |
| marker, |
| '**PR Artifacts Notice**', |
| '', |
| 'This PR contains a `.pr/` directory with temporary PR-specific documents. ' + cleanup, |
| '', |
| ].join('\n'); |
|
|
| core.warning('.pr/ directory contains temporary PR-only artifacts'); |
| if (existing) { |
| await github.rest.issues.updateComment({ |
| owner: context.repo.owner, |
| repo: context.repo.repo, |
| comment_id: existing.id, |
| body, |
| }); |
| } else { |
| await github.rest.issues.createComment({ |
| owner: context.repo.owner, |
| repo: context.repo.repo, |
| issue_number: context.issue.number, |
| body, |
| }); |
| } |
|
|
| |
| |
| cleanup-after-merge: |
| concurrency: |
| group: pr-artifacts-live-e2e-${{ github.event.pull_request.base.ref }} |
| cancel-in-progress: false |
| if: >- |
| github.event_name == 'pull_request_target' && |
| github.event.action == 'closed' && |
| github.event.pull_request.merged == true |
| runs-on: ubuntu-latest |
| permissions: |
| contents: write |
| issues: write |
| pull-requests: write |
| steps: |
| - uses: actions/checkout@v7 |
| with: |
| ref: ${{ github.event.pull_request.base.ref }} |
| token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC }} |
|
|
| - name: Create or update cleanup PR |
| id: cleanup |
| env: |
| BASE_REF: ${{ github.event.pull_request.base.ref }} |
| CLEANUP_BRANCH: automation/remove-pr-artifacts |
| GH_TOKEN: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC }} |
| run: | |
| set -euo pipefail |
| if [ ! -d ".pr" ]; then |
| echo "created=false" >> "$GITHUB_OUTPUT" |
| echo "::notice::No .pr/ directory to remove from $BASE_REF" |
| exit 0 |
| fi |
| |
| git config user.name "allhands-bot" |
| git config user.email "allhands-bot@users.noreply.github.com" |
| git fetch origin \ |
| "+refs/heads/$CLEANUP_BRANCH:refs/remotes/origin/$CLEANUP_BRANCH" || true |
| git checkout -B "$CLEANUP_BRANCH" |
| git rm -rf --ignore-unmatch .pr/ |
| if git diff --cached --quiet; then |
| echo "created=false" >> "$GITHUB_OUTPUT" |
| echo "::notice::No tracked .pr/ files to remove from $BASE_REF" |
| exit 0 |
| fi |
| git commit \ |
| -m "chore: remove merged PR artifacts" \ |
| -m "Co-authored-by: openhands <openhands@all-hands.dev>" |
| git push --force-with-lease origin "HEAD:$CLEANUP_BRANCH" |
|
|
| cleanup_pr_url=$(gh pr list \ |
| --base "$BASE_REF" \ |
| --head "$CLEANUP_BRANCH" \ |
| --state open \ |
| --json url \ |
| --jq '.[0].url') |
|
|
| if [ -z "$cleanup_pr_url" ]; then |
| cleanup_pr_url=$(gh pr create \ |
| --base "$BASE_REF" \ |
| --head "$CLEANUP_BRANCH" \ |
| --title "chore: remove merged PR artifacts" \ |
| --body-file - <<EOF |
| |
|
|
| Temporary \`.pr/\` artifacts reached \`$BASE_REF\` through one or more merged PRs. |
|
|
| |
|
|
| - Remove the temporary \`.pr/\` directory from \`$BASE_REF\`. |
|
|
| |
|
|
| - Confirm this PR only deletes files under \`.pr/\`. |
| - Let the required checks validate the cleanup commit. |
|
|
| --- |
|
|
| _This PR was automatically created by the PR Artifacts workflow._ |
| EOF |
| ) |
| fi |
|
|
| echo "created=true" >> "$GITHUB_OUTPUT" |
| echo "pr_url=$cleanup_pr_url" >> "$GITHUB_OUTPUT" |
| echo "::notice::Cleanup PR ready: $cleanup_pr_url" |
|
|
| - name: Update source PR comment |
| if: steps.cleanup.outputs.created == 'true' |
| uses: actions/github-script@v9 |
| env: |
| CLEANUP_PR_URL: ${{ steps.cleanup.outputs.pr_url }} |
| with: |
| script: | |
| const marker = '<!-- pr-artifacts-notice -->'; |
| const body = [ |
| marker, |
| '**PR Artifact Cleanup Queued**', |
| '', |
| 'The `.pr/` directory reached `' + context.payload.pull_request.base.ref + '` after merge. A cleanup PR has been opened or updated: ' + process.env.CLEANUP_PR_URL, |
| '', |
| ].join('\n'); |
| |
| const comments = await github.paginate( |
| github.rest.issues.listComments, |
| { |
| owner: context.repo.owner, |
| repo: context.repo.repo, |
| issue_number: context.issue.number, |
| per_page: 100, |
| }, |
| ); |
| const existing = comments.find((comment) => comment.body.includes(marker)); |
|
|
| if (existing) { |
| await github.rest.issues.updateComment({ |
| owner: context.repo.owner, |
| repo: context.repo.repo, |
| comment_id: existing.id, |
| body, |
| }); |
| } else { |
| await github.rest.issues.createComment({ |
| owner: context.repo.owner, |
| repo: context.repo.repo, |
| issue_number: context.issue.number, |
| body, |
| }); |
| } |
|
|