diff --git a/.github/workflows/pr-autofix-apply.yml b/.github/workflows/pr-autofix-apply.yml new file mode 100644 index 000000000..5aaa7c992 --- /dev/null +++ b/.github/workflows/pr-autofix-apply.yml @@ -0,0 +1,265 @@ +name: Apply PR autofix + +on: + workflow_run: + workflows: + - Prepare PR autofix + types: + - completed + +concurrency: + group: pr-autofix-apply-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.id }} + cancel-in-progress: true + +# A maintainer token is intentionally separate from GITHUB_TOKEN. GitHub does +# not recursively run normal PR CI for ordinary pushes made with GITHUB_TOKEN. +# Configure PR_AUTOFIX_TOKEN to enable bot commits; without it, contributors +# still receive the fully validated patch as an artifact. +permissions: + actions: read + contents: read + pull-requests: write + +jobs: + apply: + name: Apply validated patch + if: github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + env: + SOURCE_RUN_ID: ${{ github.event.workflow_run.id }} + SOURCE_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + SOURCE_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} + SOURCE_HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }} + SOURCE_HEAD_REF: ${{ github.event.workflow_run.head_branch }} + # Use a maintainer PAT with the minimum repository permissions needed to + # push PR branches. Fork PRs additionally need "Allow edits from maintainers". + PR_AUTOFIX_TOKEN: ${{ secrets.PR_AUTOFIX_TOKEN }} + steps: + # workflow_run executes this workflow from the trusted default branch. + # Nothing checked out from the PR is executed in this privileged job. + - name: Resolve current pull request + id: pr + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + pr_number="${SOURCE_PR_NUMBER:-}" + if [[ -z "$pr_number" ]]; then + pr_number="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${SOURCE_HEAD_SHA}/pulls" \ + --jq '[.[] | select(.state == "open")][0].number // empty')" + fi + + if [[ -z "$pr_number" && -n "${SOURCE_HEAD_REPO:-}" && -n "${SOURCE_HEAD_REF:-}" ]]; then + head_owner="${SOURCE_HEAD_REPO%%/*}" + pr_number="$(gh api search/issues \ + -f q="repo:${GITHUB_REPOSITORY} is:pr is:open head:${head_owner}:${SOURCE_HEAD_REF}" \ + --jq '.items[0].number // empty')" + fi + + if [[ -z "$pr_number" ]]; then + echo "run=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + pr="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}")" + state="$(jq -r '.state' <<<"$pr")" + draft="$(jq -r '.draft' <<<"$pr")" + head_sha="$(jq -r '.head.sha' <<<"$pr")" + head_repo="$(jq -r '.head.repo.full_name' <<<"$pr")" + head_ref="$(jq -r '.head.ref' <<<"$pr")" + maintainer_can_modify="$(jq -r '.maintainer_can_modify' <<<"$pr")" + same_repo="$(jq -r --arg repo "$GITHUB_REPOSITORY" '.head.repo.full_name == $repo' <<<"$pr")" + + if [[ "$state" != "open" || "$draft" != "false" || "$head_sha" != "$SOURCE_HEAD_SHA" ]]; then + echo "run=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + { + echo "run=true" + echo "pr_number=$pr_number" + echo "head_sha=$head_sha" + echo "head_repo=$head_repo" + echo "head_ref=$head_ref" + echo "maintainer_can_modify=$maintainer_can_modify" + echo "same_repo=$same_repo" + } >> "$GITHUB_OUTPUT" + + - name: Download validated artifact + if: steps.pr.outputs.run == 'true' + uses: actions/download-artifact@v8 + with: + name: pr-autofix-${{ steps.pr.outputs.pr_number }}-${{ steps.pr.outputs.head_sha }} + path: ${{ runner.temp }}/pr-autofix + github-token: ${{ github.token }} + run-id: ${{ env.SOURCE_RUN_ID }} + + - name: Verify artifact metadata + id: artifact + if: steps.pr.outputs.run == 'true' + env: + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + HEAD_REPO: ${{ steps.pr.outputs.head_repo }} + HEAD_REF: ${{ steps.pr.outputs.head_ref }} + run: | + set -euo pipefail + metadata="$RUNNER_TEMP/pr-autofix/metadata.json" + patch="$RUNNER_TEMP/pr-autofix/autofix.patch" + + test -f "$metadata" + test -f "$patch" + test "$(jq -r '.pr_number' "$metadata")" = "$PR_NUMBER" + test "$(jq -r '.head_sha' "$metadata")" = "$HEAD_SHA" + test "$(jq -r '.head_repo' "$metadata")" = "$HEAD_REPO" + test "$(jq -r '.head_ref' "$metadata")" = "$HEAD_REF" + + has_changes="$(jq -r '.has_changes' "$metadata")" + case "$has_changes" in + true|false) ;; + *) echo "::error::Invalid artifact metadata"; exit 1 ;; + esac + echo "has_changes=$has_changes" >> "$GITHUB_OUTPUT" + + - name: Clear stale autofix comment + if: steps.pr.outputs.run == 'true' && steps.artifact.outputs.has_changes != 'true' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + run: | + set -euo pipefail + marker='' + gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + --jq ".[] | select(.body | contains(\"$marker\")) | .id" \ + | while read -r comment_id; do + [[ -z "$comment_id" ]] || gh api --method DELETE "repos/${GITHUB_REPOSITORY}/issues/comments/${comment_id}" >/dev/null + done + + - name: Checkout exact PR head without credentials + if: steps.pr.outputs.run == 'true' && steps.artifact.outputs.has_changes == 'true' + env: + HEAD_REPO: ${{ steps.pr.outputs.head_repo }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: | + set -euo pipefail + mkdir work + cd work + git init + git remote add origin "https://github.com/${HEAD_REPO}.git" + git fetch --depth=1 origin "$HEAD_SHA" + git checkout --detach FETCH_HEAD + + - name: Apply and constrain validated patch + if: steps.pr.outputs.run == 'true' && steps.artifact.outputs.has_changes == 'true' + working-directory: work + run: | + set -euo pipefail + patch="$RUNNER_TEMP/pr-autofix/autofix.patch" + git apply --check "$patch" + git apply --index "$patch" + + count=0 + while IFS= read -r -d '' path; do + count=$((count + 1)) + case "$path" in + routes.txt|docs/*.md|docs/*.mdx|src/pages/*.md|src/pages/*.mdx|meetings/*.md|meetings/*.mdx|src/*.js|src/*.jsx|src/*.ts|src/*.tsx) + ;; + *) + echo "::error::Artifact attempted to change disallowed path: $path" + exit 1 + ;; + esac + done < <(git diff --cached --name-only -z) + + if (( count == 0 || count > 500 )); then + echo "::error::Unexpected autofix file count: $count" + exit 1 + fi + + size="$(git diff --cached --binary | wc -c)" + if (( size > 2097152 )); then + echo "::error::Autofix patch is larger than 2 MiB" + exit 1 + fi + + - name: Create bot commit + id: commit + if: steps.pr.outputs.run == 'true' && steps.artifact.outputs.has_changes == 'true' + working-directory: work + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit -m '🤖 Apply automated PR fixes' + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Push bot commit + id: push + if: steps.pr.outputs.run == 'true' && steps.artifact.outputs.has_changes == 'true' + working-directory: work + env: + HEAD_REPO: ${{ steps.pr.outputs.head_repo }} + HEAD_REF: ${{ steps.pr.outputs.head_ref }} + SAME_REPO: ${{ steps.pr.outputs.same_repo }} + MAINTAINER_CAN_MODIFY: ${{ steps.pr.outputs.maintainer_can_modify }} + run: | + set -euo pipefail + + if [[ -z "${PR_AUTOFIX_TOKEN:-}" ]]; then + echo "pushed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [[ "$SAME_REPO" != 'true' && "$MAINTAINER_CAN_MODIFY" != 'true' ]]; then + echo "pushed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + auth="$(printf 'x-access-token:%s' "$PR_AUTOFIX_TOKEN" | base64 | tr -d '\n')" + set +e + git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic $auth" \ + push "https://github.com/${HEAD_REPO}.git" "HEAD:refs/heads/${HEAD_REF}" + status=$? + set -e + + if (( status == 0 )); then + echo "pushed=true" >> "$GITHUB_OUTPUT" + else + echo "pushed=false" >> "$GITHUB_OUTPUT" + fi + + - name: Update fallback comment + if: steps.pr.outputs.run == 'true' && steps.artifact.outputs.has_changes == 'true' && steps.push.outputs.pushed != 'true' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ env.SOURCE_RUN_ID }} + run: | + set -euo pipefail + marker='' + body="$marker + Autofix found a validated formatting, lint, or generated-route patch, but the upstream workflow could not push to this PR branch. Download and extract the \`pr-autofix-*\` artifact from the [autofix run]($RUN_URL), then run \`git apply autofix.patch\` on the PR branch." + + comment_id="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + --jq ".[] | select(.body | contains(\"$marker\")) | .id" | head -n 1)" + + if [[ -n "$comment_id" ]]; then + gh api --method PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${comment_id}" -f body="$body" >/dev/null + else + gh api --method POST "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" -f body="$body" >/dev/null + fi + + - name: Clear fallback comment after successful push + if: steps.push.outputs.pushed == 'true' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + run: | + set -euo pipefail + marker='' + gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + --jq ".[] | select(.body | contains(\"$marker\")) | .id" \ + | while read -r comment_id; do + [[ -z "$comment_id" ]] || gh api --method DELETE "repos/${GITHUB_REPOSITORY}/issues/comments/${comment_id}" >/dev/null + done diff --git a/.github/workflows/pr-autofix.yml b/.github/workflows/pr-autofix.yml new file mode 100644 index 000000000..35c894d3c --- /dev/null +++ b/.github/workflows/pr-autofix.yml @@ -0,0 +1,172 @@ +name: Prepare PR autofix + +on: + pull_request: + branches: + - main + types: [opened, reopened, synchronize, ready_for_review] + +concurrency: + group: pr-autofix-prepare-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + autofix: + name: Format, lint, and build + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + steps: + # This workflow deliberately runs under pull_request, so fork PRs receive + # only the normal read-only token and no repository secrets. + - name: Checkout PR head read-only + uses: actions/checkout@v7.0.1 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Install pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node + uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: pnpm install --prefer-offline --frozen-lockfile + + - name: Generate formatting and lint fixes + run: | + set -euo pipefail + pnpm ci-format:mdx + pnpm lint:fix || true + git diff --binary > "$RUNNER_TEMP/style.patch" + git reset --hard "$HEAD_SHA" + git clean -fd + + - name: Generate routes.txt + run: | + set -euo pipefail + pnpm stellar-cli:build -- --cli-ref='main' + pnpm stellar-cli:fix-links + pnpm build + git diff --binary -- routes.txt > "$RUNNER_TEMP/routes.patch" + git reset --hard "$HEAD_SHA" + git clean -fd + + - name: Assemble autofix patch + id: patch + run: | + set -euo pipefail + + if [[ -s "$RUNNER_TEMP/style.patch" ]]; then + git apply "$RUNNER_TEMP/style.patch" + fi + if [[ -s "$RUNNER_TEMP/routes.patch" ]]; then + git apply "$RUNNER_TEMP/routes.patch" + fi + + git diff --binary > "$RUNNER_TEMP/autofix.patch" + if [[ -s "$RUNNER_TEMP/autofix.patch" ]]; then + echo "has_changes=true" >> "$GITHUB_OUTPUT" + else + echo "has_changes=false" >> "$GITHUB_OUTPUT" + fi + + - name: Restrict autofix output + if: steps.patch.outputs.has_changes == 'true' + run: | + set -euo pipefail + + count=0 + while IFS= read -r -d '' path; do + count=$((count + 1)) + case "$path" in + routes.txt|docs/*.md|docs/*.mdx|src/pages/*.md|src/pages/*.mdx|meetings/*.md|meetings/*.mdx|src/*.js|src/*.jsx|src/*.ts|src/*.tsx) + ;; + *) + echo "::error::Autofix attempted to change disallowed path: $path" + exit 1 + ;; + esac + done < <(git diff --name-only -z) + + if (( count > 500 )); then + echo "::error::Autofix changed $count files; refusing an unexpectedly large patch" + exit 1 + fi + + size="$(wc -c < "$RUNNER_TEMP/autofix.patch")" + if (( size > 2097152 )); then + echo "::error::Autofix patch is larger than 2 MiB" + exit 1 + fi + + - name: Stage validation tree + if: steps.patch.outputs.has_changes == 'true' + run: | + set -euo pipefail + git add -A + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit -m 'Autofix validation tree' + + - name: Check MDX formatting + run: git update-index -g && pnpm ci:mdx + + - name: Lint source + run: pnpm lint + + - name: Check new absolute internal links + run: | + set -euo pipefail + git remote add base "https://github.com/${GITHUB_REPOSITORY}.git" + git fetch base "$BASE_REF" --depth=1 + scripts/check-relative-links.sh --range "base/${BASE_REF}...HEAD" \ + || { echo "::error::New absolute /docs link(s) found above."; exit 1; } + + - name: Validate full docs build + run: | + set -euo pipefail + routes_before="$(git hash-object routes.txt)" + pnpm stellar-cli:build -- --cli-ref='main' + pnpm stellar-cli:fix-links + pnpm build + routes_after="$(git hash-object routes.txt)" + if [[ "$routes_before" != "$routes_after" ]]; then + echo "::error::routes.txt still changes after the autofix pass" + exit 1 + fi + + - name: Prepare artifact metadata + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/pr-autofix" + cp "$RUNNER_TEMP/autofix.patch" "$RUNNER_TEMP/pr-autofix/autofix.patch" + jq -n \ + --argjson pr_number '${{ github.event.pull_request.number }}' \ + --arg head_sha '${{ github.event.pull_request.head.sha }}' \ + --arg head_repo '${{ github.event.pull_request.head.repo.full_name }}' \ + --arg head_ref '${{ github.event.pull_request.head.ref }}' \ + --argjson maintainer_can_modify '${{ github.event.pull_request.maintainer_can_modify }}' \ + --argjson has_changes '${{ steps.patch.outputs.has_changes }}' \ + '{pr_number: $pr_number, head_sha: $head_sha, head_repo: $head_repo, head_ref: $head_ref, maintainer_can_modify: $maintainer_can_modify, has_changes: $has_changes}' \ + > "$RUNNER_TEMP/pr-autofix/metadata.json" + + - name: Upload validated autofix + uses: actions/upload-artifact@v7 + with: + name: pr-autofix-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }} + path: ${{ runner.temp }}/pr-autofix + retention-days: 7