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 = ''; 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 = ''; 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, }); } # Fork branches cannot be modified reliably. If artifacts reach the trusted base, # remove them through a pull request so branch protections remain enforced. 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 " 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 - <> "$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 = ''; 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, }); }