From f74e14d77b25685cbc076b4af07b5fb71dcc49eb Mon Sep 17 00:00:00 2001 From: LeoAnders Date: Tue, 17 Feb 2026 23:46:50 -0300 Subject: [PATCH] ci(sync-upstream): harden automated upstream merge flow --- .github/workflows/AGENTS.md | 46 ++++++ .github/workflows/sync-upstream.yml | 246 ++++++++++++++++++++++++++++ AGENTS.md | 4 + 3 files changed, 296 insertions(+) create mode 100644 .github/workflows/AGENTS.md create mode 100644 .github/workflows/sync-upstream.yml diff --git a/.github/workflows/AGENTS.md b/.github/workflows/AGENTS.md new file mode 100644 index 0000000..eb791d5 --- /dev/null +++ b/.github/workflows/AGENTS.md @@ -0,0 +1,46 @@ +# Workflow Guidelines + +## Scope + +These instructions apply to files under `.github/workflows/`. + +## General CI Rules + +- Keep workflows idempotent and safe for scheduled re-runs. +- Prefer explicit shell safety in script blocks: `set -euo pipefail`. +- Use clear step names and stable step outputs for control flow. +- Avoid destructive git operations unless strictly required and justified by workflow logic. + +## Branch Governance + +- Never push directly to `master` from workflows. +- Use dedicated bot branches for automation flows. +- Changes to protected branches must enter via Pull Request. +- Prefer merge commit strategy when repository governance requires audit-friendly history. + +## Upstream Sync Workflow (`sync-upstream.yml`) + +- Purpose: synchronize fork changes from `intersystems/language-server` into this fork. +- Schedule: daily at `03:00 UTC` (equivalent to `00:00 America/Sao_Paulo`). +- Invariants: + - Sync branch is `bot/sync-upstream-master`. + - No direct writes to `master`. + - PR only flow (`bot/sync-upstream-master` -> `master`). + - Merge mode must be merge commit (`--merge`), not squash/rebase. +- Safety behavior: + - Exit with no action if upstream tip is already contained in base. + - Only create PR when there is real diff between base and sync branch. + - Preserve manual work on sync branch when present; avoid blind reset. + - Treat auto-merge as best-effort (must not fail the whole job if unavailable). +- Conflict behavior: + - Keep sync branch available for manual resolution. + - Comment on PR with manual resolution instructions when possible. + +## Repository Setup Requirements + +- GitHub Actions enabled. +- `GITHUB_TOKEN` permissions include: + - `contents: write` + - `pull-requests: write` +- Repository allows merge commits (and optional auto-merge). +- Optional secret `GOOGLE_CHAT_WEBHOOK_URL` for Chat notifications. diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml new file mode 100644 index 0000000..ae6276f --- /dev/null +++ b/.github/workflows/sync-upstream.yml @@ -0,0 +1,246 @@ +name: Sync upstream (daily) + +on: + schedule: + # GitHub cron é UTC. + # 03:00 UTC ≈ 00:00 America/Sao_Paulo. + - cron: "0 3 * * *" + workflow_dispatch: {} + +permissions: + contents: write + pull-requests: write + +concurrency: + group: sync-upstream + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + + env: + UPSTREAM_REPO: intersystems/language-server + UPSTREAM_BRANCH: master + BASE_BRANCH: master + SYNC_BRANCH: bot/sync-upstream-master + + steps: + - name: Checkout base + uses: actions/checkout@v4 + with: + ref: ${{ env.BASE_BRANCH }} + fetch-depth: 0 + + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Add upstream and fetch + run: | + git remote add upstream https://github.com/${UPSTREAM_REPO}.git + git fetch upstream --prune + + - name: Check if Chat webhook is configured + id: chat + env: + CHAT_WEBHOOK: ${{ secrets.GOOGLE_CHAT_WEBHOOK_URL }} + run: | + if [ -n "${CHAT_WEBHOOK}" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + fi + + - name: Check if upstream has new commits + id: diff + run: | + set -euo pipefail + BASE_SHA="$(git rev-parse HEAD)" + UP_SHA="$(git rev-parse upstream/${UPSTREAM_BRANCH})" + echo "base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT" + echo "upstream_sha=$UP_SHA" >> "$GITHUB_OUTPUT" + + # Só precisa sync quando o topo do upstream ainda NÃO está contido na base. + if git merge-base --is-ancestor "upstream/${UPSTREAM_BRANCH}" "$BASE_SHA"; then + echo "needs_update=false" >> "$GITHUB_OUTPUT" + else + echo "needs_update=true" >> "$GITHUB_OUTPUT" + fi + + - name: Stop if no updates + if: steps.diff.outputs.needs_update != 'true' + run: echo "No updates. Nothing to do." + + - name: Find existing sync PR + if: steps.diff.outputs.needs_update == 'true' + id: pr_lookup + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + PR_NUMBER="$(gh pr list --head "${SYNC_BRANCH}" --base "${BASE_BRANCH}" --state open --json number -q '.[0].number' || true)" + if [ -z "$PR_NUMBER" ]; then + echo "has_pr=false" >> "$GITHUB_OUTPUT" + else + echo "has_pr=true" >> "$GITHUB_OUTPUT" + fi + echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + + - name: Prepare sync branch + if: steps.diff.outputs.needs_update == 'true' + id: prepare_branch + run: | + set -euo pipefail + if git ls-remote --exit-code --heads origin "${SYNC_BRANCH}" >/dev/null 2>&1; then + git fetch origin "${SYNC_BRANCH}" + if [ "${{ steps.pr_lookup.outputs.has_pr }}" = "true" ]; then + git checkout -B "${SYNC_BRANCH}" "origin/${SYNC_BRANCH}" + echo "branch_mode=reused_open_pr" >> "$GITHUB_OUTPUT" + else + UNIQUE_ON_SYNC="$(git rev-list --count "origin/${BASE_BRANCH}..origin/${SYNC_BRANCH}")" + if [ "$UNIQUE_ON_SYNC" -gt 0 ]; then + # Existe trabalho na branch de sync sem PR aberto: preservar para não perder contexto manual. + git checkout -B "${SYNC_BRANCH}" "origin/${SYNC_BRANCH}" + echo "branch_mode=reused_manual_work" >> "$GITHUB_OUTPUT" + else + git checkout -B "${SYNC_BRANCH}" "${BASE_BRANCH}" + git push --force-with-lease origin "${SYNC_BRANCH}" + echo "branch_mode=reset_to_base" >> "$GITHUB_OUTPUT" + fi + fi + else + git checkout -B "${SYNC_BRANCH}" "${BASE_BRANCH}" + git push -u origin "${SYNC_BRANCH}" + echo "branch_mode=created_from_base" >> "$GITHUB_OUTPUT" + fi + + - name: Attempt merge upstream into sync branch (merge commit) + if: steps.diff.outputs.needs_update == 'true' + id: merge_result + run: | + set -euo pipefail + git checkout "${SYNC_BRANCH}" + # Força merge commit quando possível + if git merge --no-ff --no-edit "upstream/${UPSTREAM_BRANCH}"; then + echo "merged=true" >> "$GITHUB_OUTPUT" + else + git merge --abort || true + echo "merged=false" >> "$GITHUB_OUTPUT" + fi + + - name: Push merged result + if: steps.diff.outputs.needs_update == 'true' && steps.merge_result.outputs.merged == 'true' + run: | + git push origin "${SYNC_BRANCH}" + + - name: Check if sync branch has PR diff + if: steps.diff.outputs.needs_update == 'true' + id: diff_for_pr + run: | + set -euo pipefail + git fetch origin "${BASE_BRANCH}" "${SYNC_BRANCH}" --prune + DIFF_COMMITS="$(git rev-list --count "origin/${BASE_BRANCH}..origin/${SYNC_BRANCH}")" + if [ "$DIFF_COMMITS" -gt 0 ]; then + echo "has_diff=true" >> "$GITHUB_OUTPUT" + else + echo "has_diff=false" >> "$GITHUB_OUTPUT" + fi + echo "diff_commits=$DIFF_COMMITS" >> "$GITHUB_OUTPUT" + + - name: Create or update PR + if: steps.diff.outputs.needs_update == 'true' + id: pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + TITLE="Sync upstream/${UPSTREAM_BRANCH} → ${BASE_BRANCH}" + BODY="$(printf '%s\n\n%s\n%s\n%s\n%s\n\n%s' \ + "Este PR sincroniza o fork com **${UPSTREAM_REPO}:${UPSTREAM_BRANCH}**." \ + "- Base: \`${BASE_BRANCH}\`" \ + "- Branch de sync: \`${SYNC_BRANCH}\`" \ + "- Upstream SHA: \`${{ steps.diff.outputs.upstream_sha }}\`" \ + "- Merge automático no branch de sync: \`${{ steps.merge_result.outputs.merged }}\`" \ + "Se houver conflito, o workflow vai comentar aqui pedindo intervenção manual.")" + + PR_NUMBER="${{ steps.pr_lookup.outputs.pr_number }}" + if [ -n "$PR_NUMBER" ]; then + gh pr edit "$PR_NUMBER" --title "$TITLE" --body "$BODY" + else + if [ "${{ steps.diff_for_pr.outputs.has_diff }}" = "true" ]; then + gh pr create \ + --base "${BASE_BRANCH}" \ + --head "${SYNC_BRANCH}" \ + --title "$TITLE" \ + --body "$BODY" + PR_NUMBER="$(gh pr list --head "${SYNC_BRANCH}" --base "${BASE_BRANCH}" --state open --json number -q '.[0].number')" + else + PR_NUMBER="" + fi + fi + + echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + if [ -n "$PR_NUMBER" ]; then + echo "pr_url=https://github.com/$GITHUB_REPOSITORY/pull/$PR_NUMBER" >> "$GITHUB_OUTPUT" + else + echo "pr_url=" >> "$GITHUB_OUTPUT" + fi + + - name: Log when conflict has no open PR + if: steps.diff.outputs.needs_update == 'true' && steps.merge_result.outputs.merged != 'true' && steps.pr.outputs.pr_number == '' + run: | + echo "Merge conflict detected, and no valid PR diff exists yet." + echo "Resolve manually on branch ${SYNC_BRANCH}; then open a PR to ${BASE_BRANCH}." + + - name: Enable auto-merge (MERGE, not squash/rebase) + if: steps.diff.outputs.needs_update == 'true' && steps.merge_result.outputs.merged == 'true' && steps.pr.outputs.pr_number != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr merge "${{ steps.pr.outputs.pr_number }}" --auto --merge || echo "Auto-merge not enabled/available." + + - name: Comment on PR if conflicts happened + if: steps.diff.outputs.needs_update == 'true' && steps.merge_result.outputs.merged != 'true' && steps.pr.outputs.pr_number != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BODY="$(printf '%s\n\n%s\n%s\n%s\n%s\n%s\n\n%s' \ + '⚠️ **Conflito ao sincronizar com upstream**.' \ + 'Para resolver manualmente:' \ + "1) \`git checkout ${SYNC_BRANCH}\`" \ + '2) `git fetch upstream`' \ + "3) \`git merge upstream/${UPSTREAM_BRANCH}\`" \ + '4) `Resolver conflitos, git add ., git commit, git push`' \ + "PR: https://github.com/${GITHUB_REPOSITORY}/pull/${{ steps.pr.outputs.pr_number }}")" + gh pr comment "${{ steps.pr.outputs.pr_number }}" --body "$BODY" + + - name: Notify Google Chat (success) + if: steps.chat.outputs.enabled == 'true' && steps.diff.outputs.needs_update == 'true' && steps.merge_result.outputs.merged == 'true' && steps.pr.outputs.pr_url != '' + env: + WEBHOOK: ${{ secrets.GOOGLE_CHAT_WEBHOOK_URL }} + MSG: >- + ✅ Sync do upstream concluído (merge commit) e PR marcado para auto-merge. + Repo: ${{ github.repository }} + PR: ${{ steps.pr.outputs.pr_url }} + Upstream SHA: ${{ steps.diff.outputs.upstream_sha }} + run: | + curl -sS -X POST "$WEBHOOK" \ + -H 'Content-Type: application/json' \ + -d "$(python3 -c 'import json, os; print(json.dumps({"text": os.environ["MSG"]}))')" + + - name: Notify Google Chat (conflict) + if: steps.chat.outputs.enabled == 'true' && steps.diff.outputs.needs_update == 'true' && steps.merge_result.outputs.merged != 'true' + env: + WEBHOOK: ${{ secrets.GOOGLE_CHAT_WEBHOOK_URL }} + MSG: >- + ⚠️ Sync do upstream encontrou CONFLITO e precisa intervenção manual. + Repo: ${{ github.repository }} + PR: ${{ steps.pr.outputs.pr_url }} + Branch: ${{ env.SYNC_BRANCH }} + Upstream SHA: ${{ steps.diff.outputs.upstream_sha }} + run: | + curl -sS -X POST "$WEBHOOK" \ + -H 'Content-Type: application/json' \ + -d "$(python3 -c 'import json, os; print(json.dumps({"text": os.environ["MSG"]}))')" diff --git a/AGENTS.md b/AGENTS.md index 133a95b..3017450 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,3 +26,7 @@ This file applies to the **entire repository**. More specific instructions in su - `server/lib/isclexer.node` is gitignored and must exist locally for runs that use the native lexer. - Preferred: run `npm run select-isclexer` from repo root (auto-selects for the current OS/arch). - Cross-build: set `ISCLEXER_TARGET=-` (e.g. `win32-x64`) before running `npm run select-isclexer` / `npm run webpack`. + +## Workflow-Specific Guidance + +- For CI/CD workflow rules and upstream sync governance, follow `.github/workflows/AGENTS.md`.