diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index c4da1c7eb..080248228 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -19,7 +19,6 @@ jobs: pr-check: runs-on: ubuntu-latest permissions: - pull-requests: write contents: read steps: - uses: actions/checkout@v4 @@ -62,9 +61,35 @@ jobs: run: cargo build --release --manifest-path ci/Cargo.toml -p pr-check - name: Run pr-check + id: run-check env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }} + # For pull_request events (including forks), write the comment to a + # file instead of posting it directly. The fork's GITHUB_TOKEN does + # not have write access to the base repository, so direct posting + # returns 403. The pr-comment workflow picks up this artifact and + # posts the comment with the right permissions. + COMMENT_OUTPUT_FILE: ${{ github.event_name == 'pull_request' && 'pr-check-output/comment.md' || '' }} run: | - ci/target/release/pr-check ${{ steps.changed.outputs.files }} + mkdir -p pr-check-output + echo "$PR_NUMBER" > pr-check-output/pr_number.txt + if ci/target/release/pr-check ${{ steps.changed.outputs.files }}; then + echo "passed" > pr-check-output/result.txt + else + echo "failed" > pr-check-output/result.txt + fi + + - name: Upload check results + if: always() && github.event_name == 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: pr-check-output + path: pr-check-output/ + + - name: Fail if checks did not pass + if: always() + run: | + result=$(cat pr-check-output/result.txt 2>/dev/null || echo "failed") + [ "$result" = "passed" ] diff --git a/.github/workflows/pr-comment.yml b/.github/workflows/pr-comment.yml new file mode 100644 index 000000000..9639652e7 --- /dev/null +++ b/.github/workflows/pr-comment.yml @@ -0,0 +1,40 @@ +name: PR Check Comment + +on: + workflow_run: + workflows: ["PR Check"] + types: [completed] + +jobs: + comment: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Download check results + uses: actions/download-artifact@v4 + with: + name: pr-check-output + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + continue-on-error: true + + - name: Post or update PR comment + if: hashFiles('pr_number.txt') != '' && hashFiles('comment.md') != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + PR_NUMBER=$(cat pr_number.txt) + COMMENT_BODY=$(cat comment.md) + + EXISTING_ID=$(gh api "repos/$GH_REPO/issues/$PR_NUMBER/comments" \ + --jq '[.[] | select(.body | contains(""))] | first | .id // empty') + + if [ -n "$EXISTING_ID" ]; then + gh api --method PATCH "repos/$GH_REPO/issues/comments/$EXISTING_ID" \ + --field body="$COMMENT_BODY" + else + gh api --method POST "repos/$GH_REPO/issues/$PR_NUMBER/comments" \ + --field body="$COMMENT_BODY" + fi diff --git a/ci/pr-check/src/main.rs b/ci/pr-check/src/main.rs index 480f80c81..5bff8459e 100644 --- a/ci/pr-check/src/main.rs +++ b/ci/pr-check/src/main.rs @@ -9,14 +9,22 @@ //! - More than one contributor //! - Repository is at least 3 months old //! -//! The results are posted as a single comment on the PR (updating an existing -//! bot comment if one already exists). The process exits with a non-zero status -//! code when any hard criterion is not met, causing CI to fail. +//! The results are either posted as a single comment on the PR (updating an +//! existing bot comment if one already exists) or written to a file when the +//! `COMMENT_OUTPUT_FILE` environment variable is set. The latter mode is used +//! in CI to work around the GitHub Actions restriction that prevents fork PRs +//! from writing to the base repository. A separate `pr-comment` workflow then +//! picks up the file and posts the comment with the necessary permissions. +//! +//! The process exits with a non-zero status code when any hard criterion is +//! not met, causing CI to fail. //! //! Expected environment variables: -//! GITHUB_TOKEN - a token with `pull-requests: write` permission -//! GITHUB_REPOSITORY - owner/repo, e.g. "analysis-tools-dev/static-analysis" -//! PR_NUMBER - the pull request number +//! GITHUB_TOKEN - a token with `pull-requests: write` permission +//! GITHUB_REPOSITORY - owner/repo, e.g. "analysis-tools-dev/static-analysis" +//! PR_NUMBER - the pull request number +//! COMMENT_OUTPUT_FILE - (optional) path to write the rendered comment body +//! to instead of posting it directly via the API. use anyhow::{Context, Result, bail}; use askama::Template; @@ -470,7 +478,22 @@ async fn main() -> Result<()> { let comment_body = render_comment(&reports)?; - upsert_comment(&client, &gh_repo, pr_number, &comment_body).await?; + // If COMMENT_OUTPUT_FILE is set, write the comment to that file instead of + // posting it via the API. This is used by the `pull_request` CI workflow to + // avoid the 403 that GitHub returns when a fork PR tries to write comments. + // A separate `pr-comment` workflow picks up the file and posts the comment + // with the write permissions it has as a `workflow_run` job. + if let Ok(output_file) = env::var("COMMENT_OUTPUT_FILE") { + if let Some(parent) = std::path::Path::new(&output_file).parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory for {output_file}"))?; + } + std::fs::write(&output_file, &comment_body) + .with_context(|| format!("Failed to write comment to {output_file}"))?; + eprintln!("Comment written to {output_file}"); + } else { + upsert_comment(&client, &gh_repo, pr_number, &comment_body).await?; + } let any_failures = reports.iter().any(|r| r.any_fail()); if any_failures {