diff --git a/.github/workflows/autoupdate.yml b/.github/workflows/autoupdate.yml new file mode 100644 index 0000000..e12e787 --- /dev/null +++ b/.github/workflows/autoupdate.yml @@ -0,0 +1,248 @@ +name: Autoupdate +on: + schedule: + - cron: "0 23 * * 5" + workflow_dispatch: + workflow_call: +concurrency: + group: "${{ github.workflow }} @ ${{ github.ref }}" + cancel-in-progress: false +permissions: + contents: write + actions: write + pull-requests: write + issues: write +env: + AUTOUPDATE_BRANCH: chore/autoupdate-${{ github.run_id }} +jobs: + update: + name: Autoupdate dependencies + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Checkout repo + uses: actions/checkout@v6 + with: + ref: main + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + - name: Configure git + run: | + git config user.email "siarhei@dudko.dev" + git config user.name "Siarhei Dudko" + - name: Create autoupdate branch + run: | + git checkout -b "$AUTOUPDATE_BRANCH" + git push -u origin "$AUTOUPDATE_BRANCH" + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Run autoupdater + id: autoupdate + continue-on-error: true + uses: siarheidudko/autoupdater@v6 + with: + author-email: "siarhei@dudko.dev" + author-name: "Siarhei Dudko" + working-directory: ${{ github.workspace }} + ref: ${{ github.repository }} + branch: ${{ env.AUTOUPDATE_BRANCH }} + builds-and-checks: | + npm run typecheck + npm run format:check + npm run build + npm test + debug: "true" + ignore-packages: | + @types/node + - name: Persist autoupdater work on failure + if: steps.autoupdate.outcome == 'failure' + run: | + if [ -n "$(git status --porcelain)" ]; then + git add -A + git commit -m "chore(deps): autoupdater partial update" + fi + git push --force-with-lease origin "HEAD:$AUTOUPDATE_BRANCH" || true + - name: Fallback baseline update if branch still empty vs main + if: steps.autoupdate.outcome == 'failure' + run: | + git fetch origin main + if git diff --quiet origin/main; then + npx --yes npm-check-updates -u || true + npm install --no-audit --no-fund || true + if [ -n "$(git status --porcelain)" ]; then + git add -A + git commit -m "chore(deps): npm-check-updates baseline (autoupdater fallback)" + git push origin "HEAD:$AUTOUPDATE_BRANCH" + fi + fi + - name: Bump patch version on the failure path + if: steps.autoupdate.outcome == 'failure' + run: | + # The autoupdater bumps the version itself, but only on the success + # path. Neither the persist step nor the npm-check-updates fallback + # touches it, so without this the dependency update merges into the + # default branch, release-on-version-bump sees no version change and + # never fires, and the update is never published. + git fetch origin main + git show origin/main:package.json > /tmp/base-package.json + BASE=$(node -p "require('/tmp/base-package.json').version") + CURRENT=$(node -p "require('$PWD/package.json').version") + if [ "$BASE" != "$CURRENT" ]; then + echo "version already bumped: $BASE -> $CURRENT" + else + npm --no-git-tag-version --ignore-scripts version patch + NEW=$(node -p "require('$PWD/package.json').version") + git add package.json package-lock.json + git commit -m "chore(release): v$NEW" + git push origin "HEAD:$AUTOUPDATE_BRANCH" + echo "bumped $BASE -> $NEW" + fi + - name: Ensure labels exist + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh label create autoupdate --color "0e8a16" --description "Automated dependency update PRs" --force || true + gh label create needs-claude --color "d4c5f9" --description "Needs Claude GitHub App to fix" --force || true + - name: Detect diff vs main + id: diff + if: always() + run: | + git fetch origin main "$AUTOUPDATE_BRANCH" + if git diff --quiet "origin/main" "origin/$AUTOUPDATE_BRANCH"; then + echo "has_diff=false" >> "$GITHUB_OUTPUT" + else + echo "has_diff=true" >> "$GITHUB_OUTPUT" + fi + - name: Read package version from branch + id: pkg + if: steps.diff.outputs.has_diff == 'true' + run: | + VERSION=$(git show "origin/$AUTOUPDATE_BRANCH:package.json" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.parse(s).version))") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: Open PR (autoupdater succeeded) + id: pr_success + if: | + steps.autoupdate.outcome == 'success' && + steps.autoupdate.outputs.updated == 'true' && + steps.diff.outputs.has_diff == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BODY=$(cat <> "$GITHUB_OUTPUT" + - name: Open PR (autoupdater failed) + id: pr_failure + if: steps.autoupdate.outcome == 'failure' && steps.diff.outputs.has_diff == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + BODY=$(cat <> "$GITHUB_OUTPUT" + - name: Trigger PR checks (GITHUB_TOKEN can't auto-trigger pull_request) + if: steps.pr_success.outputs.pr_url != '' || steps.pr_failure.outputs.pr_url != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh workflow run ci.yml --ref "$AUTOUPDATE_BRANCH" || true + - name: Wait for PR checks and merge (autoupdater succeeded) + id: merge_pr + if: steps.pr_success.outputs.pr_url != '' + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ steps.pr_success.outputs.pr_url }} + run: | + # Poll the check-runs API instead of `gh pr checks --watch`. + # The pull_request run GitHub queues for a bot-authored PR is gated on + # manual approval and sits in `action_required` forever. It surfaces in + # the status rollup that `gh pr checks` reads, so `--watch` waited on a + # check that can never complete and this step hung until the job timed + # out, never reaching `gh pr merge`. Gated runs create no check runs, so + # this view sees only jobs that actually execute -- among them the run + # the previous step dispatched explicitly. + SHA=$(gh pr view "$PR_URL" --json headRefOid --jq .headRefOid) + for i in $(seq 1 40); do + RUNS=$(gh api "repos/$GITHUB_REPOSITORY/commits/$SHA/check-runs" --jq .check_runs) + TOTAL=$(jq length <<<"$RUNS") + PENDING=$(jq '[.[] | select(.status != "completed")] | length' <<<"$RUNS") + [ "$TOTAL" -gt 0 ] && [ "$PENDING" -eq 0 ] && break + sleep 30 + done + FAILED=$(jq '[.[] | select(.conclusion != null and (.conclusion | IN("success","neutral","skipped") | not))] | length' <<<"$RUNS") + echo "checks: total=$TOTAL pending=$PENDING failed=$FAILED" + if [ "$TOTAL" -eq 0 ] || [ "$PENDING" -ne 0 ] || [ "$FAILED" -ne 0 ]; then + echo "not merging: checks are not green" + exit 1 + fi + gh pr merge "$PR_URL" --squash --delete-branch + + - name: Dispatch Claude to fix the failed autoupdate + if: steps.pr_failure.outputs.pr_url != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ steps.pr_failure.outputs.pr_url }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + gh workflow run claude.yml --ref main \ + -f branch="$AUTOUPDATE_BRANCH" \ + -f pr_url="$PR_URL" \ + -f run_url="$RUN_URL" + - name: Add labels to PR + if: steps.pr_success.outputs.pr_url != '' || steps.pr_failure.outputs.pr_url != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ steps.pr_success.outputs.pr_url || steps.pr_failure.outputs.pr_url }} + run: | + if [ "${{ steps.pr_failure.outputs.pr_url }}" != "" ]; then + gh pr edit "$PR_URL" --add-label autoupdate --add-label needs-claude || true + else + gh pr edit "$PR_URL" --add-label autoupdate || true + fi + - name: Cleanup branch when nothing to ship + if: always() && steps.diff.outputs.has_diff != 'true' + run: | + git push origin --delete "$AUTOUPDATE_BRANCH" || true diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000..246d61e --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,158 @@ +name: Claude +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + pull_request_review: + types: [submitted] + issues: + types: [opened, assigned] + workflow_dispatch: + inputs: + branch: + description: "Branch Claude should operate on (used by autoupdate flow)" + required: true + type: string + pr_url: + description: "PR URL where Claude should post a status comment" + required: false + type: string + run_url: + description: "URL of the failing autoupdate run, included in the prompt for context" + required: false + type: string +concurrency: + group: "${{ github.workflow }} @ ${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs.branch }}" + cancel-in-progress: false +jobs: + claude: + name: Run Claude + if: | + github.event_name == 'workflow_dispatch' || + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + pull-requests: write + issues: write + id-token: write + actions: write + steps: + - name: Checkout repo + uses: actions/checkout@v6 + with: + ref: ${{ github.event.inputs.branch || github.ref }} + fetch-depth: 1 + - name: Prepare autoupdate-fix prompt + if: github.event_name == 'workflow_dispatch' + id: prep + env: + BRANCH: ${{ github.event.inputs.branch }} + PR_URL: ${{ github.event.inputs.pr_url }} + RUN_URL: ${{ github.event.inputs.run_url }} + run: | + { + echo 'prompt<" + + The comment must state plainly: + - Which command(s) failed initially (or "all green on first run"). + - What changes you made (or "no fix needed"). + - Whether you pushed any commits, and the SHA(s) if so. + + Do NOT exit without posting this comment. The maintainer relies + on it to know what happened without reading the action log. + + See CLAUDE.md in the repo root for the full project conventions. + EOF + echo PROMPT_EOF + } >> "$GITHUB_OUTPUT" + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + allowed_bots: "*" + prompt: ${{ steps.prep.outputs.prompt }} + claude_args: | + --allowedTools "Edit,Write,MultiEdit,Bash(git:*),Bash(gh:*),Bash(npm:*),Bash(npx:*),Bash(node:*),Bash(rm:*),Bash(mkdir:*),Bash(cat:*),Bash(ls:*),Bash(echo:*),Bash(grep:*),Bash(find:*),Bash(sed:*),Bash(awk:*),Bash(head:*),Bash(tail:*),Bash(diff:*),Bash(mv:*),Bash(cp:*),Bash(touch:*)" + # Claude pushes with GITHUB_TOKEN, so its push fires no `push` run and the + # `pull_request` run GitHub queues for it stays stuck in `action_required`. + # Dispatch the checks explicitly — `workflow_dispatch` is the only trigger + # that is not gated. Runs even if the step above failed, so the PR ends up + # with a red check instead of no checks at all. + - name: Re-dispatch PR checks on the branch Claude pushed to + if: always() && github.event_name == 'workflow_dispatch' && github.event.inputs.branch != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BRANCH: ${{ github.event.inputs.branch }} + run: | + git fetch origin "$BRANCH" + echo "Dispatching checks for $BRANCH @ $(git rev-parse --short "origin/$BRANCH")" + gh workflow run ci.yml --ref "$BRANCH" || true + # Without this the autoupdate flow has no ending: the success path merges + # itself, but a PR that needed a Claude fix stayed open forever even once + # its checks were green, so these PRs just piled up week after week. + - name: Merge once the dispatched checks are green + if: always() && github.event_name == 'workflow_dispatch' && github.event.inputs.pr_url != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ github.event.inputs.pr_url }} + run: | + # Poll check-runs, not `gh pr checks`: the approval-gated pull_request + # run shows up as a permanently-pending check in the status rollup but + # produces no check runs, so this view sees only jobs that really ran. + SHA=$(gh pr view "$PR_URL" --json headRefOid --jq .headRefOid) + for i in $(seq 1 40); do + RUNS=$(gh api "repos/$GITHUB_REPOSITORY/commits/$SHA/check-runs" --jq .check_runs) + TOTAL=$(jq length <<<"$RUNS") + PENDING=$(jq '[.[] | select(.status != "completed")] | length' <<<"$RUNS") + [ "$TOTAL" -gt 0 ] && [ "$PENDING" -eq 0 ] && break + sleep 30 + done + FAILED=$(jq '[.[] | select(.conclusion != null and (.conclusion | IN("success","neutral","skipped") | not))] | length' <<<"$RUNS") + echo "checks: total=$TOTAL pending=$PENDING failed=$FAILED" + if [ "$TOTAL" -eq 0 ] || [ "$PENDING" -ne 0 ] || [ "$FAILED" -ne 0 ]; then + echo "leaving the PR open for review: checks are not green" + exit 0 + fi + gh pr merge "$PR_URL" --squash --delete-branch