diff --git a/.github/workflows/sync-branches.lock.yml b/.github/workflows/sync-branches.lock.yml index 9926eeb..89d965b 100644 --- a/.github/workflows/sync-branches.lock.yml +++ b/.github/workflows/sync-branches.lock.yml @@ -273,12 +273,6 @@ jobs: run: bash ${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh env: GH_TOKEN: ${{ github.token }} - - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - GITHUB_REPOSITORY: ${{ github.repository }} - name: Merge default branch into all autoloop program branches - run: "python3 - << 'PYEOF'\nimport os, subprocess, sys\n\ntoken = os.environ.get(\"GITHUB_TOKEN\", \"\")\nrepo = os.environ.get(\"GITHUB_REPOSITORY\", \"\")\ndefault_branch = os.environ.get(\"DEFAULT_BRANCH\", \"main\")\n\n# Discover all remote branches matching the autoloop/* pattern.\n# Use ls-remote instead of 'git branch -r' so we don't depend on\n# pre-fetched remote-tracking refs (shallow checkouts won't have them).\nresult = subprocess.run(\n [\"git\", \"ls-remote\", \"--heads\", \"origin\", \"autoloop/*\"],\n capture_output=True, text=True\n)\nif result.returncode != 0:\n print(f\"Failed to list remote branches: {result.stderr}\")\n sys.exit(0)\n\nimport re as _re\nbranches = [_re.sub(r\"^.*refs/heads/\", \"\", b.strip()) for b in result.stdout.strip().split(\"\\n\") if b.strip()]\n\nif not branches:\n print(\"No autoloop/* branches found. Nothing to sync.\")\n sys.exit(0)\n\nprint(f\"Found {len(branches)} autoloop branch(es) to sync: {branches}\")\n\nfailed = []\nfor branch in branches:\n print(f\"\\n--- Syncing {branch} with {default_branch} ---\")\n\n # Fetch both branches\n subprocess.run([\"git\", \"fetch\", \"origin\", branch], capture_output=True)\n subprocess.run([\"git\", \"fetch\", \"origin\", default_branch], capture_output=True)\n\n # Check out the program branch\n checkout = subprocess.run(\n [\"git\", \"checkout\", branch],\n capture_output=True, text=True\n )\n if checkout.returncode != 0:\n # Try creating a local tracking branch\n checkout = subprocess.run(\n [\"git\", \"checkout\", \"-b\", branch, f\"origin/{branch}\"],\n capture_output=True, text=True\n )\n if checkout.returncode != 0:\n print(f\" Failed to checkout {branch}: {checkout.stderr}\")\n failed.append(branch)\n continue\n\n # Merge the default branch into the program branch\n merge = subprocess.run(\n [\"git\", \"merge\", f\"origin/{default_branch}\", \"--no-edit\",\n \"-m\", f\"Merge {default_branch} into {branch}\"],\n capture_output=True, text=True\n )\n if merge.returncode != 0:\n print(f\" Merge conflict or failure for {branch}: {merge.stderr}\")\n # Abort the merge to leave a clean state\n subprocess.run([\"git\", \"merge\", \"--abort\"], capture_output=True)\n failed.append(branch)\n continue\n\n # Push the updated branch\n push = subprocess.run(\n [\"git\", \"push\", \"origin\", branch],\n capture_output=True, text=True\n )\n if push.returncode != 0:\n print(f\" Failed to push {branch}: {push.stderr}\")\n failed.append(branch)\n continue\n\n print(f\" Successfully synced {branch}\")\n\n# Return to default branch\nsubprocess.run([\"git\", \"checkout\", default_branch], capture_output=True)\n\nif failed:\n print(f\"\\n⚠️ Failed to sync {len(failed)} branch(es): {failed}\")\n print(\"These branches may need manual conflict resolution.\")\n # Don't fail the workflow — log the issue but continue\nelse:\n print(f\"\\n✅ All {len(branches)} branch(es) synced successfully.\")\nPYEOF" - - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} @@ -291,6 +285,11 @@ jobs: SERVER_URL_STRIPPED="${SERVER_URL#https://}" git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" echo "Git configured with standard GitHub Actions identity" + - env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GITHUB_REPOSITORY: ${{ github.repository }} + name: Merge default branch into all autoloop program branches + run: "python3 - << 'PYEOF'\nimport os, subprocess, sys\n\ntoken = os.environ.get(\"GITHUB_TOKEN\", \"\")\nrepo = os.environ.get(\"GITHUB_REPOSITORY\", \"\")\ndefault_branch = os.environ.get(\"DEFAULT_BRANCH\", \"main\")\n\n# List all remote branches matching the autoloop/* pattern\nresult = subprocess.run(\n [\"git\", \"branch\", \"-r\", \"--list\", \"origin/autoloop/*\"],\n capture_output=True, text=True\n)\nif result.returncode != 0:\n print(f\"Failed to list remote branches: {result.stderr}\")\n sys.exit(0)\n\nbranches = [b.strip().replace(\"origin/\", \"\") for b in result.stdout.strip().split(\"\\n\") if b.strip()]\n\nif not branches:\n print(\"No autoloop/* branches found. Nothing to sync.\")\n sys.exit(0)\n\nprint(f\"Found {len(branches)} autoloop branch(es) to sync: {branches}\")\n\nfailed = []\nfor branch in branches:\n print(f\"\\n--- Syncing {branch} with {default_branch} ---\")\n\n # Fetch both branches\n subprocess.run([\"git\", \"fetch\", \"origin\", branch], capture_output=True)\n subprocess.run([\"git\", \"fetch\", \"origin\", default_branch], capture_output=True)\n\n # Check out the program branch\n checkout = subprocess.run(\n [\"git\", \"checkout\", branch],\n capture_output=True, text=True\n )\n if checkout.returncode != 0:\n # Try creating a local tracking branch\n checkout = subprocess.run(\n [\"git\", \"checkout\", \"-b\", branch, f\"origin/{branch}\"],\n capture_output=True, text=True\n )\n if checkout.returncode != 0:\n print(f\" Failed to checkout {branch}: {checkout.stderr}\")\n failed.append(branch)\n continue\n\n # Merge the default branch into the program branch\n merge = subprocess.run(\n [\"git\", \"merge\", f\"origin/{default_branch}\", \"--no-edit\",\n \"-m\", f\"Merge {default_branch} into {branch}\"],\n capture_output=True, text=True\n )\n if merge.returncode != 0:\n print(f\" Merge conflict or failure for {branch}: {merge.stderr}\")\n # Abort the merge to leave a clean state\n subprocess.run([\"git\", \"merge\", \"--abort\"], capture_output=True)\n failed.append(branch)\n continue\n\n # Push the updated branch\n push = subprocess.run(\n [\"git\", \"push\", \"origin\", branch],\n capture_output=True, text=True\n )\n if push.returncode != 0:\n print(f\" Failed to push {branch}: {push.stderr}\")\n failed.append(branch)\n continue\n\n print(f\" Successfully synced {branch}\")\n\n# Return to default branch\nsubprocess.run([\"git\", \"checkout\", default_branch], capture_output=True)\n\nif failed:\n print(f\"\\n⚠️ Failed to sync {len(failed)} branch(es): {failed}\")\n print(\"These branches may need manual conflict resolution.\")\n # Don't fail the workflow — log the issue but continue\nelse:\n print(f\"\\n✅ All {len(branches)} branch(es) synced successfully.\")\nPYEOF" - name: Checkout PR branch id: checkout-pr if: | diff --git a/tests/test_scheduling.py b/tests/test_scheduling.py index a3e1ebf..4944c3c 100644 --- a/tests/test_scheduling.py +++ b/tests/test_scheduling.py @@ -759,3 +759,77 @@ def test_clone_before_scheduling(self): f"'{self.CLONE_STEP}' (index {clone_idx}) must come before " f"'{self.SCHED_STEP}' (index {sched_idx}). Steps: {steps}" ) + + +class TestSyncBranchesCredentialOrdering: + """Verify that Git credentials are configured before the merge/push step. + + The sync-branches workflow merges the default branch into autoloop/* + branches. Merge commits require a Git identity (user.name/user.email) + and pushes/fetches need an authenticated remote URL. Both must be + configured before the merge step runs. + """ + + CRED_STEP = "Set up Git identity and authentication" + MERGE_STEP = "Merge default branch into all autoloop program branches" + + def _load_steps(self): + """Return the list of pre-step names from workflows/sync-branches.md.""" + import os + + wf_path = os.path.join(os.path.dirname(__file__), "..", "workflows", "sync-branches.md") + with open(wf_path) as f: + content = f.read() + step_names = [] + for m in re.finditer(r'^\s*-\s*name:\s*(.+)$', content, re.MULTILINE): + step_names.append(m.group(1).strip()) + return step_names + + def _load_lock_steps(self): + """Return the list of step names from .github/workflows/sync-branches.lock.yml.""" + import os + import yaml + + lock_path = os.path.join( + os.path.dirname(__file__), "..", ".github", "workflows", "sync-branches.lock.yml" + ) + with open(lock_path) as f: + data = yaml.safe_load(f) + # Collect step names from the 'agent' job + steps = data.get("jobs", {}).get("agent", {}).get("steps", []) + return [s.get("name", "") for s in steps if s.get("name")] + + def test_cred_step_exists(self): + """A step that configures Git identity/auth must exist in the source.""" + steps = self._load_steps() + assert self.CRED_STEP in steps, ( + f"Expected step '{self.CRED_STEP}' not found. Steps: {steps}" + ) + + def test_creds_before_merge(self): + """The credential step must come before the merge step in the source.""" + steps = self._load_steps() + cred_idx = steps.index(self.CRED_STEP) + merge_idx = steps.index(self.MERGE_STEP) + assert cred_idx < merge_idx, ( + f"'{self.CRED_STEP}' (index {cred_idx}) must come before " + f"'{self.MERGE_STEP}' (index {merge_idx}). Steps: {steps}" + ) + + def test_lock_creds_before_merge(self): + """In the compiled lock file, Configure Git credentials must come before the merge step.""" + steps = self._load_lock_steps() + cred_names = [s for s in steps if "Configure Git credentials" in s] + assert cred_names, ( + f"No 'Configure Git credentials' step found in lock file. Steps: {steps}" + ) + merge_names = [s for s in steps if "Merge default branch" in s] + assert merge_names, ( + f"No merge step found in lock file. Steps: {steps}" + ) + cred_idx = steps.index(cred_names[0]) + merge_idx = steps.index(merge_names[0]) + assert cred_idx < merge_idx, ( + f"'Configure Git credentials' (index {cred_idx}) must come before " + f"merge step (index {merge_idx}). Steps: {steps}" + ) diff --git a/workflows/sync-branches.md b/workflows/sync-branches.md index 232086d..ccb3cc1 100644 --- a/workflows/sync-branches.md +++ b/workflows/sync-branches.md @@ -20,6 +20,34 @@ tools: bash: true steps: + - name: Set up Git identity and authentication + env: + GH_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_SERVER_URL: ${{ github.server_url }} + run: | + node - << 'JSEOF' + const { spawnSync } = require('child_process'); + function git(...args) { + const result = spawnSync('git', args, { encoding: 'utf-8' }); + if (result.status !== 0) { + console.error('git ' + args.join(' ') + ' failed: ' + result.stderr); + process.exit(1); + } + return result; + } + git('config', '--global', 'user.email', 'github-actions[bot]@users.noreply.github.com'); + git('config', '--global', 'user.name', 'github-actions[bot]'); + const ghToken = process.env.GH_TOKEN || ''; + const serverUrl = process.env.GITHUB_SERVER_URL || 'https://github.com'; + const repo = process.env.GITHUB_REPOSITORY || ''; + if (ghToken && repo) { + const authUrl = serverUrl.replace('https://', 'https://x-access-token:' + ghToken + '@') + '/' + repo + '.git'; + git('remote', 'set-url', 'origin', authUrl); + } + console.log('Git identity and authentication configured.'); + JSEOF + - name: Merge default branch into all autoloop program branches env: GITHUB_REPOSITORY: ${{ github.repository }}