diff --git a/.github/workflows/pr-artifacts.yml b/.github/workflows/pr-artifacts.yml new file mode 100644 index 00000000..ee68efb0 --- /dev/null +++ b/.github/workflows/pr-artifacts.yml @@ -0,0 +1,294 @@ +--- +name: PR Artifacts + +on: + workflow_dispatch: # Manual trigger for testing + pull_request_target: + types: [opened, synchronize, reopened, closed] + branches: [main] + pull_request_review: + types: [submitted] + +jobs: + # Auto-remove .pr/ directory from same-repository PRs when approved. + cleanup-on-approval: + concurrency: + group: cleanup-pr-artifacts-${{ github.event.pull_request.number }} + cancel-in-progress: false + if: >- + github.event_name == 'pull_request_review' && + github.event.review.state == 'approved' && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + # Use PAT so the push triggers CI workflows that will complete and + # satisfy branch protection. We can't use [skip ci] because GitHub Apps + # can create stuck checks that block merging. + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.ref }} + token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC }} + + - name: Remove .pr/ directory + id: remove + run: | + if [ -d ".pr" ]; then + git config user.name "allhands-bot" + git config user.email "allhands-bot@users.noreply.github.com" + git rm -rf .pr/ + git commit -m "chore: Remove PR-only artifacts [automated]" + git push || { + echo "::error::Failed to push cleanup commit. Check branch protection rules." + exit 1 + } + echo "removed=true" >> "$GITHUB_OUTPUT" + echo "::notice::Removed .pr/ directory" + else + echo "removed=false" >> "$GITHUB_OUTPUT" + echo "::notice::No .pr/ directory to remove" + fi + + - name: Update PR comment after cleanup + if: 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 automatically removed. + `; + + 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(c => c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: body, + }); + } + + # Inspect the fork through the API; never check out untrusted code with a write token. + check-pr-artifacts: + if: >- + github.event_name == 'pull_request_target' && + github.event.action != 'closed' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Post or update PR 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(c => c.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. + `, + }); + } + 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} + `; + + 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: body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: 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: cleanup-pr-artifacts-${{ 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 + 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: | + 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 .pr/ + 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} + `; + + 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(c => c.body.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body, + }); + } diff --git a/.github/workflows/pr-description-check.yml b/.github/workflows/pr-description-check.yml index e80ace92..5a1d4a47 100644 --- a/.github/workflows/pr-description-check.yml +++ b/.github/workflows/pr-description-check.yml @@ -17,13 +17,15 @@ jobs: validate-pr-description: name: Validate PR description # Draft PRs may still have incomplete descriptions; validate when review starts. - # Release PRs are generated by the trusted prepare-release workflow without + # Release and artifact-cleanup PRs are generated by trusted workflows without # the standard human-authored PR template. if: >- github.event.pull_request.draft == false && github.event.pull_request.user.login != 'dependabot[bot]' && !(github.event.pull_request.head.repo.full_name == github.repository && - startsWith(github.event.pull_request.head.ref, 'release-please-')) + (startsWith(github.event.pull_request.head.ref, 'release-please-') || + (github.event.pull_request.base.ref == 'main' && + github.event.pull_request.head.ref == 'automation/remove-pr-artifacts'))) runs-on: ubuntu-latest steps: - name: Checkout trusted workflow scripts diff --git a/.pr/custom-automation-event-trigger.png b/.pr/custom-automation-event-trigger.png deleted file mode 100644 index 0eae8024..00000000 Binary files a/.pr/custom-automation-event-trigger.png and /dev/null differ diff --git a/.pr/custom-automation-plugin-fields.png b/.pr/custom-automation-plugin-fields.png deleted file mode 100644 index c812bc0f..00000000 Binary files a/.pr/custom-automation-plugin-fields.png and /dev/null differ diff --git a/.pr/custom-automation-prompt-review.png b/.pr/custom-automation-prompt-review.png deleted file mode 100644 index d94f3f2b..00000000 Binary files a/.pr/custom-automation-prompt-review.png and /dev/null differ diff --git a/.pr/custom-automation-prompt-scheduled.png b/.pr/custom-automation-prompt-scheduled.png deleted file mode 100644 index a1aafef9..00000000 Binary files a/.pr/custom-automation-prompt-scheduled.png and /dev/null differ diff --git a/.pr/custom-automation-upload-fields.png b/.pr/custom-automation-upload-fields.png deleted file mode 100644 index 15453c84..00000000 Binary files a/.pr/custom-automation-upload-fields.png and /dev/null differ diff --git a/AGENTS.md b/AGENTS.md index a9eae36a..18b33532 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,6 +118,31 @@ When editing or adding skills in this repo, follow these rules (and add new skil - `plugins/release-notes` now has a standalone validator at `plugins/release-notes/scripts/validate_release_notes.py`; it rebuilds the deterministic tag-range context, fails if a change bullet omits explicit PR/commit refs or matching author handles, and enforces full PR/author coverage by appending a compact `### ๐Ÿ”Ž Small Fixes/Internal Changes` appendix grouped by author when the agent omits lower-signal items. New contributor detection in `generate_release_notes.py` should use merged PR history for human authors (excluding bots) rather than commit-author lookup. +## PR-specific documents (`.pr/`) + +When working on a PR that requires design documents, live-test logs, development-only scripts, or other temporary artifacts that should **not** be merged to `main`, store them in a `.pr/` directory at the repository root. + +```bash +mkdir -p .pr + +.pr/ +โ”œโ”€โ”€ design.md # Design decisions and architecture notes +โ”œโ”€โ”€ analysis.md # Investigation or debugging notes +โ””โ”€โ”€ notes.md # Any other PR-specific content +``` + +The `PR Artifacts` workflow (`.github/workflows/pr-artifacts.yml`) owns the lifecycle of this directory: + +1. **Notification**: When a PR contains `.pr/`, a single comment is posted to the PR conversation alerting reviewers. +2. **Auto-cleanup on approval**: For same-repository PRs, the directory is automatically removed by a follow-up commit when the PR is approved. +3. **Post-merge cleanup**: If artifacts reach `main`, including through a fork PR, the workflow opens or updates a cleanup PR against `main`. + +Important notes: + +- Do not put anything in `.pr/` that needs to be preserved. +- The `.pr/` check is informational during development; it posts a notice rather than blocking the PR. +- Cleanup PRs follow the normal review and required-check protections for `main`. + ## CI / validation gotchas - The test suite expects **every directory under `skills/`** to be listed in a marketplace. If you add a new skill (or rebase onto a main branch that added skills), update the appropriate marketplace file or CI will fail with `Skills missing from marketplace: [...]`.