From dbd0877bdcbb8fae3cd38fe730dd53c71ee2ef74 Mon Sep 17 00:00:00 2001 From: bobra200 Date: Mon, 29 Jun 2026 16:15:52 -0700 Subject: [PATCH 1/5] ci: install sync-branches workflow via automation bundle --- .github/actions/sync-branches/README.md | 280 ++++++++++++++++++ .github/actions/sync-branches/action.yml | 225 ++++++++++++++ .../sync-branches/consumer-template.yml | 44 +++ .../sync-branches/scripts/move-major-tag.sh | 64 ++++ .github/automation-install.lock.json | 25 ++ .github/workflows/sync-develop-to-main.yml | 44 +++ 6 files changed, 682 insertions(+) create mode 100644 .github/actions/sync-branches/README.md create mode 100644 .github/actions/sync-branches/action.yml create mode 100644 .github/actions/sync-branches/consumer-template.yml create mode 100755 .github/actions/sync-branches/scripts/move-major-tag.sh create mode 100644 .github/automation-install.lock.json create mode 100644 .github/workflows/sync-develop-to-main.yml diff --git a/.github/actions/sync-branches/README.md b/.github/actions/sync-branches/README.md new file mode 100644 index 0000000..11ff251 --- /dev/null +++ b/.github/actions/sync-branches/README.md @@ -0,0 +1,280 @@ +# Sync Branches Action + +Automated branch synchronization that keeps a target branch (for example `main`) in sync with a source branch (for example `develop`). + +## Features + +- **Automatic triggers**: Runs on every push to source branch or manual dispatch +- **Clean merges**: Direct push to target branch when no conflicts exist +- **Conflict handling**: Automatically opens a PR for manual resolution if conflicts occur +- **Idempotent**: Skips sync if source is already in target (no unnecessary merges) +- **Safer token policy**: Push-triggered runs use `github.token`; optional PAT is limited to manual dispatch +- **Customizable**: Configure branches, labels, reviewers, and commit identity + +## Quick Start + +### 1. Copy The Consumer Template + +Copy `actions/sync-branches/consumer-template.yml` into your consumer repo as `.github/workflows/sync-develop-to-main.yml`. + +The default template uses: +- `rdk-e/app-gateway-automation/actions/sync-branches@actions-v1` +- `runs-on: comcast-ubuntu-latest` +- `source_branch: develop` +- `target_branch: main` + +### 2. Optional: Add PAT Secret For Manual Dispatch + +If you want manual dispatch runs to use a PAT (for protected branch scenarios), add `SEMANTIC_RELEASE_TOKEN` in the consumer repo. + +Required PAT scopes: +- `contents:write` +- `pull-requests:write` +- `issues:write` + +Push-triggered runs still use `github.token` by design. + +### 3. Example Consumer Workflow + +```yaml +name: Sync develop to main + +on: + push: + branches: [develop] + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + runs-on: comcast-ubuntu-latest + steps: + - uses: rdk-e/app-gateway-automation/actions/sync-branches@actions-v1 + with: + source_branch: ${{ github.event.inputs.source_branch || 'develop' }} + target_branch: ${{ github.event.inputs.target_branch || 'main' }} + token: ${{ github.event_name == 'workflow_dispatch' && (secrets.SEMANTIC_RELEASE_TOKEN || github.token) || github.token }} +``` + +### 4. Optional: Generate From Installer + +You can generate a repo-specific workflow from `tools/install-workflow.sh`: + +```bash +./tools/install-workflow.sh \ + --workflow sync-develop-to-main \ + --repo-dir ../firebolt-entos-apis \ + --source-branch develop \ + --target-branch main \ + --secret-name SEMANTIC_RELEASE_TOKEN \ + --runner comcast-ubuntu-latest \ + --open-pr +``` + +This writes `.github/workflows/sync-develop-to-main.yml` in the target repo. + +## OSS Bundle Installer (Manifest + Lock) + +For OSS repositories that cannot directly consume private reusable actions, use the bundle installer flow: + +1. Download release assets from `app-gateway-automation`: +- `sync-branches-.tar.gz` +- `sync-branches-.manifest.yaml` +- `sync-branches-.sha256` + +2. Install into the OSS repo: + +```bash +./tools/install-automation-bundle.sh install \ + --repo-dir /path/to/oss-repo \ + --bundle /path/to/sync-branches-.tar.gz +``` + +3. Check current state: + +```bash +./tools/install-automation-bundle.sh status \ + --repo-dir /path/to/oss-repo \ + --bundle /path/to/sync-branches-.tar.gz +``` + +4. Update to a newer bundle: + +```bash +./tools/install-automation-bundle.sh update \ + --repo-dir /path/to/oss-repo \ + --bundle /path/to/sync-branches-.tar.gz +``` + +Managed files: +- `.github/actions/sync-branches/` +- `.github/workflows/sync-develop-to-main.yml` +- `.github/automation-install.lock.json` + +Drift policy: +- Managed file drift is blocked by default. +- Use `--force` to overwrite managed drift. + +## Release Then Onboard Consumers + +For fastest rollout, use this order: + +1. Run `.github/workflows/release-actions.yml` on `main` to publish `actions-vX.Y.Z`. +2. Confirm the floating major tag (`actions-v1`) moved to the new release. +3. Install consumer workflow from template/installer (already pinned to `@actions-v1`). +4. In each consumer repo, ensure permissions include `contents: write`, `pull-requests: write`, and `issues: write`. +5. Trigger `workflow_dispatch` once to validate token and branch settings. + +## Usage Examples + +### Example 1: Simple develop -> main sync + +```yaml +jobs: + sync: + runs-on: comcast-ubuntu-latest + steps: + - uses: rdk-e/app-gateway-automation/actions/sync-branches@actions-v1 + with: + token: ${{ github.token }} +``` + +### Example 2: Custom branches with token + +```yaml +jobs: + sync: + runs-on: comcast-ubuntu-latest + steps: + - uses: rdk-e/app-gateway-automation/actions/sync-branches@actions-v1 + with: + source_branch: staging + target_branch: production + token: ${{ secrets.MY_PAT }} +``` + +### Example 3: Custom PR behavior + +```yaml +jobs: + sync: + runs-on: comcast-ubuntu-latest + steps: + - uses: rdk-e/app-gateway-automation/actions/sync-branches@actions-v1 + with: + assign_reviewers: "@platform-team,@devops-team" + pr_labels: "auto-merge,requires-review" + git_user_name: "Release Bot" + git_user_email: "releases@company.com" + token: ${{ secrets.SEMANTIC_RELEASE_TOKEN }} +``` + +## Inputs + +| Input | Default | Description | +|-------|---------|-------------| +| `source_branch` | `develop` | Branch to sync from | +| `target_branch` | `main` | Branch to sync to | +| `git_user_name` | `github-actions[bot]` | Committer name for merge commits | +| `git_user_email` | `github-actions[bot]@users.noreply.github.com` | Committer email | +| `pr_branch_prefix` | `auto-sync` | Prefix for fallback PR branches | +| `assign_reviewers` | `` | Comma-separated team/user handles for PR assignment | +| `pr_labels` | `auto-sync,needs-review` | Comma-separated labels for fallback PR | + +## Token Input + +| Input | Required | Description | +|--------|----------|-------------| +| `token` | No | Auth token for push/PR operations. Falls back to `github.token` if not provided by caller workflow. | + +## Behavior + +### Success Case: No Conflicts +``` +push to develop + ↓ +workflow runs + ↓ +merge-base check: develop NOT in main ✓ + ↓ +attempt merge: success ✓ + ↓ +direct push to main: success ✓ + ↓ +✅ main updated, no PR created +``` + +### Fallback Case: Conflict or Protected Branch +``` +push to develop + ↓ +workflow runs + ↓ +merge-base check: develop NOT in main ✓ + ↓ +attempt merge: CONFLICT ✗ (or direct push fails due to protection) + ↓ +create branch: auto-sync/develop-to-main + ↓ +open PR with labels & reviewers + ↓ +⚠️ PR #123 created for manual resolution +``` + +### Idempotent Case: Already Synced +``` +push to develop (but develop already in main) + ↓ +workflow runs + ↓ +merge-base check: develop IS in main ✓ + ↓ +✅ Skip sync (nothing to do) +``` + +## Token Scope Reference + +For branch-protected targets, use a fine-grained PAT with: + +``` +Permissions: + - Contents: Read & write + - Pull requests: Read & write + +Repository access: + - All repositories (or specific repo) +``` + +[Create fine-grained PAT](https://github.com/settings/personal-access-tokens/new) + +## Troubleshooting + +**Q: Workflow runs but push fails to protected branch** +- Ensure your token has `contents:write`, `pull-requests:write`, and `issues:write` +- If you are using the template, verify `SEMANTIC_RELEASE_TOKEN` exists when you need PAT-backed dispatch +- Confirm the workflow expression passes `github.token` for push runs and optional PAT only for dispatch runs + +**Q: PR opens but I don't want it** +- If it's just checking feature, use `workflow_dispatch` instead of automatic trigger +- Adjust branch protection rules if conflicts are expected + +**Q: I need to sync multiple branch pairs** +- Create separate workflow files, each calling sync-branches with different `source_branch`/`target_branch` +- Or use a matrix job in your calling workflow + +## File Location + +This integration guide is stored in `actions/sync-branches/` for easy discovery. + +The composite action is at `actions/sync-branches/action.yml`. + +Versioned releases are published from `actions-vX.Y.Z` tags and include the action surface (`action.yml`, `consumer-template.yml`, `README.md`, `CHANGELOG.md`, and `scripts/`). + +--- + +**Integration Branch**: `feat/reusable-sync-automation` + +For the latest stable version, check the main branch or GitHub Releases. diff --git a/.github/actions/sync-branches/action.yml b/.github/actions/sync-branches/action.yml new file mode 100644 index 0000000..194fa6a --- /dev/null +++ b/.github/actions/sync-branches/action.yml @@ -0,0 +1,225 @@ +name: Sync Branches +description: > + Merge source branch into target branch and push directly. + On conflict or push failure, opens a fallback PR instead. + All logic is here — callers only need concurrency + permissions + triggers. + +inputs: + source_branch: + description: "Branch to sync from" + required: false + default: "develop" + target_branch: + description: "Branch to sync to" + required: false + default: "main" + token: + description: "PAT with contents:write + pull-requests:write + issues:write. Falls back to GITHUB_TOKEN then github.token." + required: false + default: "" + git_user_name: + description: "Git committer name" + required: false + default: "github-actions[bot]" + git_user_email: + description: "Git committer email" + required: false + default: "github-actions[bot]@users.noreply.github.com" + pr_branch_prefix: + description: "Prefix for fallback PR branch" + required: false + default: "auto-sync" + assign_reviewers: + description: "Comma-separated reviewers to assign to fallback PR" + required: false + default: "" + pr_labels: + description: "Comma-separated labels to apply to fallback PR" + required: false + default: "auto-sync,needs-manual-merge" + +outputs: + result: + description: "Outcome: merged | skipped | pr-opened" + value: ${{ steps.outcome.outputs.result }} + pr_number: + description: "Fallback PR number (if opened)" + value: ${{ steps.outcome.outputs.pr_number }} + +runs: + using: composite + steps: + - name: Resolve authentication token + id: token + shell: bash + env: + INPUT_TOKEN: ${{ inputs.token }} + run: | + if [ -n "${INPUT_TOKEN}" ]; then + echo "token=${INPUT_TOKEN}" >> "$GITHUB_OUTPUT" + echo "source=input:token" >> "$GITHUB_OUTPUT" + elif [ -n "${GITHUB_TOKEN}" ]; then + echo "token=${GITHUB_TOKEN}" >> "$GITHUB_OUTPUT" + echo "source=env:GITHUB_TOKEN" >> "$GITHUB_OUTPUT" + else + echo "token=${{ github.token }}" >> "$GITHUB_OUTPUT" + echo "source=implicit:github.token" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout full history + uses: actions/checkout@v4 + with: + token: ${{ steps.token.outputs.token }} + fetch-depth: 0 + + - name: Configure git identity + shell: bash + run: | + git config user.name "${{ inputs.git_user_name }}" + git config user.email "${{ inputs.git_user_email }}" + + - name: Check if sync is needed + id: check + shell: bash + run: | + git fetch origin \ + "refs/heads/${{ inputs.source_branch }}:refs/remotes/origin/${{ inputs.source_branch }}" \ + "refs/heads/${{ inputs.target_branch }}:refs/remotes/origin/${{ inputs.target_branch }}" + if git merge-base --is-ancestor "origin/${{ inputs.source_branch }}" "origin/${{ inputs.target_branch }}"; then + echo "already_synced=true" >> "$GITHUB_OUTPUT" + echo "${{ inputs.target_branch }} already contains all commits from ${{ inputs.source_branch }} — nothing to do." + else + echo "already_synced=false" >> "$GITHUB_OUTPUT" + fi + + - name: Attempt merge + if: steps.check.outputs.already_synced == 'false' + id: merge + shell: bash + run: | + git checkout -B "${{ inputs.target_branch }}" "origin/${{ inputs.target_branch }}" + if git merge --no-edit "origin/${{ inputs.source_branch }}"; then + echo "conflict=false" >> "$GITHUB_OUTPUT" + else + echo "conflict=true" >> "$GITHUB_OUTPUT" + git merge --abort || true + fi + + - name: Push merged result + if: steps.check.outputs.already_synced == 'false' && steps.merge.outputs.conflict == 'false' + id: push + shell: bash + run: | + if git push origin "${{ inputs.target_branch }}"; then + echo "push_failed=false" >> "$GITHUB_OUTPUT" + echo "✅ Pushed ${{ inputs.source_branch }} → ${{ inputs.target_branch }} successfully." + else + echo "push_failed=true" >> "$GITHUB_OUTPUT" + echo "Direct push failed (branch protection). Falling back to PR." + fi + + - name: Ensure labels exist + if: steps.check.outputs.already_synced == 'false' && (steps.merge.outputs.conflict == 'true' || steps.push.outputs.push_failed == 'true') + shell: bash + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + run: | + IFS=',' read -ra LABELS <<< "${{ inputs.pr_labels }}" + for label in "${LABELS[@]}"; do + label=$(echo "$label" | xargs) + [ -n "$label" ] && gh label create "$label" --force || true + done + + - name: Open or update fallback PR + if: steps.check.outputs.already_synced == 'false' && (steps.merge.outputs.conflict == 'true' || steps.push.outputs.push_failed == 'true') + id: pr_fallback + shell: bash + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + run: | + PR_BRANCH="${{ inputs.pr_branch_prefix }}/${{ inputs.source_branch }}-to-${{ inputs.target_branch }}" + REASON="merge conflict" + if [ "${{ steps.merge.outputs.conflict }}" != "true" ]; then + REASON="direct push failure (branch protection)" + fi + + EXISTING=$(gh pr list \ + --base "${{ inputs.target_branch }}" \ + --state open \ + --json number,headRefName 2>/dev/null | \ + jq -r --arg prefix "$PR_BRANCH" 'map(select(.headRefName | startswith($prefix))) | .[0].number // empty' || echo "") + + if [ -z "$EXISTING" ]; then + if git ls-remote --exit-code --heads origin "$PR_BRANCH" >/dev/null 2>&1; then + PR_BRANCH="${PR_BRANCH}-${GITHUB_RUN_ID}" + fi + git checkout -B "$PR_BRANCH" "origin/${{ inputs.source_branch }}" + git push origin "$PR_BRANCH" + + PR_CREATE_ARGS=( + "pr" "create" + "--base" "${{ inputs.target_branch }}" + "--head" "$PR_BRANCH" + "--title" "chore: auto-sync ${{ inputs.source_branch }} → ${{ inputs.target_branch }} (${REASON})" + "--body" "Automatic sync of \`${{ inputs.source_branch }}\` → \`${{ inputs.target_branch }}\` could not complete. **Action required:** resolve then merge. **Reason:** ${REASON}. Triggered by: ${{ github.sha }} on ${{ github.ref_name }}" + ) + + if [ -n "${{ inputs.pr_labels }}" ]; then + IFS=',' read -ra LABELS <<< "${{ inputs.pr_labels }}" + for label in "${LABELS[@]}"; do + label=$(echo "$label" | xargs) + [ -n "$label" ] && PR_CREATE_ARGS+=("--label" "$label") + done + fi + + if [ -n "${{ inputs.assign_reviewers }}" ]; then + IFS=',' read -ra REVIEWERS <<< "${{ inputs.assign_reviewers }}" + for reviewer in "${REVIEWERS[@]}"; do + reviewer=$(echo "$reviewer" | xargs) + [ -n "$reviewer" ] && PR_CREATE_ARGS+=("--reviewer" "$reviewer") + done + fi + + PR_URL=$(gh "${PR_CREATE_ARGS[@]}") + PR_NUM=$(gh pr view "$PR_URL" --json number --jq .number 2>/dev/null || echo "") + if [ -z "$PR_NUM" ]; then + PR_NUM=$(gh pr list \ + --base "${{ inputs.target_branch }}" \ + --head "$PR_BRANCH" \ + --state open \ + --json number --jq '.[0].number // empty' 2>/dev/null || echo "") + fi + if [ -z "$PR_NUM" ]; then + echo "Failed to resolve fallback PR number after creation." + exit 1 + fi + echo "pr_number=${PR_NUM}" >> "$GITHUB_OUTPUT" + echo "PR #${PR_NUM} opened for manual resolution." + else + echo "pr_number=${EXISTING}" >> "$GITHUB_OUTPUT" + if [ "${{ steps.merge.outputs.conflict }}" != "true" ]; then + # Push-failure: update branch to latest source (no conflict resolution in progress) + UPDATE_BRANCH=$(gh pr view "$EXISTING" --json headRefName --jq .headRefName 2>/dev/null || echo "$PR_BRANCH") + git checkout -B "$UPDATE_BRANCH" "origin/${{ inputs.source_branch }}" + git push --force-with-lease origin "$UPDATE_BRANCH" + echo "PR #${EXISTING} branch updated to latest ${{ inputs.source_branch }} commits on $UPDATE_BRANCH." + else + echo "PR #${EXISTING} already open with merge conflict — leaving branch untouched." + fi + fi + + - name: Determine outcome + id: outcome + if: always() + shell: bash + run: | + if [ "${{ steps.check.outputs.already_synced }}" = "true" ]; then + echo "result=skipped" >> "$GITHUB_OUTPUT" + elif [ "${{ steps.merge.outputs.conflict }}" = "false" ] && [ "${{ steps.push.outputs.push_failed }}" = "false" ]; then + echo "result=merged" >> "$GITHUB_OUTPUT" + else + echo "result=pr-opened" >> "$GITHUB_OUTPUT" + [ -n "${{ steps.pr_fallback.outputs.pr_number }}" ] && \ + echo "pr_number=${{ steps.pr_fallback.outputs.pr_number }}" >> "$GITHUB_OUTPUT" + exit 1 + fi diff --git a/.github/actions/sync-branches/consumer-template.yml b/.github/actions/sync-branches/consumer-template.yml new file mode 100644 index 0000000..d208790 --- /dev/null +++ b/.github/actions/sync-branches/consumer-template.yml @@ -0,0 +1,44 @@ +# Sync develop -> main -- consumer template +# +# Copy this file to .github/workflows/sync-develop-to-main.yml in any consumer repo. +# The sync logic lives in app-gateway-automation/actions/sync-branches. +# +# Optional secret: SEMANTIC_RELEASE_TOKEN (contents:write + pull-requests:write + issues:write) +# Used only for workflow_dispatch runs (falls back to github.token if absent). +# Push-triggered runs always use github.token to keep PAT scope out of routine automation. + +name: Sync develop to main + +on: + push: + branches: [develop] + workflow_dispatch: + inputs: + source_branch: + description: "Source branch" + required: false + default: "develop" + target_branch: + description: "Target branch" + required: false + default: "main" + +concurrency: + group: sync-${{ github.repository }}-${{ github.event_name == 'workflow_dispatch' && format('{0}-to-{1}', github.event.inputs.source_branch || 'develop', github.event.inputs.target_branch || 'main') || format('{0}-to-{1}', github.ref_name, 'main') }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + sync: + name: Merge ${{ github.event.inputs.source_branch || 'develop' }} → ${{ github.event.inputs.target_branch || 'main' }} + runs-on: comcast-ubuntu-latest # use org runner label; change if repo uses a different label + steps: + - uses: rdk-e/app-gateway-automation/actions/sync-branches@actions-v1 + with: + source_branch: ${{ github.event.inputs.source_branch || 'develop' }} + target_branch: ${{ github.event.inputs.target_branch || 'main' }} + token: ${{ github.event_name == 'workflow_dispatch' && (secrets.SEMANTIC_RELEASE_TOKEN || github.token) || github.token }} diff --git a/.github/actions/sync-branches/scripts/move-major-tag.sh b/.github/actions/sync-branches/scripts/move-major-tag.sh new file mode 100755 index 0000000..3112512 --- /dev/null +++ b/.github/actions/sync-branches/scripts/move-major-tag.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Move floating major tag to point to the new release +# Usage: move-major-tag.sh --version +# Example: move-major-tag.sh --version 1.2.3 +# → creates/updates actions-v1 to point to actions-v1.2.3 + +VERSION="" +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + VERSION="$2" + shift 2 + ;; + *) + shift + ;; + esac +done + +if [ -z "$VERSION" ]; then + echo "ERROR: Usage: move-major-tag.sh --version " + exit 1 +fi + +# Skip prerelease versions (e.g. 1.2.3-rc.1) — don't move the stable major tag +if [[ "$VERSION" == *-* ]]; then + echo "Skipping major tag update for prerelease version: $VERSION" + exit 0 +fi + +# Extract major version from semver +MAJOR=$(echo "$VERSION" | cut -d. -f1) + +# Configure git if needed +if ! git config user.name 2>/dev/null; then + git config user.name "github-actions[bot]" +fi +if ! git config user.email 2>/dev/null; then + git config user.email "github-actions[bot]@users.noreply.github.com" +fi + +# Move floating major tag +MAJOR_TAG="actions-v${MAJOR}" +VERSION_TAG="actions-v${VERSION}" +TARGET_COMMIT=$(git rev-parse "${VERSION_TAG}^{commit}" 2>/dev/null || true) + +if [ -z "$TARGET_COMMIT" ]; then + echo "ERROR: Failed to resolve commit for $VERSION_TAG" + exit 1 +fi + +git tag -f "$MAJOR_TAG" "$TARGET_COMMIT" || { + echo "ERROR: Failed to tag $MAJOR_TAG -> $TARGET_COMMIT" + exit 1 +} + +git push origin "refs/tags/$MAJOR_TAG" --force || { + echo "ERROR: Failed to push $MAJOR_TAG" + exit 1 +} + +echo "Moved $MAJOR_TAG → $VERSION_TAG ($TARGET_COMMIT)" diff --git a/.github/automation-install.lock.json b/.github/automation-install.lock.json new file mode 100644 index 0000000..9e8b56b --- /dev/null +++ b/.github/automation-install.lock.json @@ -0,0 +1,25 @@ +{ + "schema_version": 1, + "installer_version": "1.0.0", + "installed_at": "2026-06-29T23:05:35Z", + "modules": [ + { + "module": "sync-branches", + "version": "1.0.99-test", + "source": "sync-branches-1.0.99-test.tar.gz", + "managed_files": [ + ".github/actions/sync-branches/action.yml", + ".github/actions/sync-branches/consumer-template.yml", + ".github/actions/sync-branches/README.md", + ".github/actions/sync-branches/scripts/move-major-tag.sh", + ".github/workflows/sync-develop-to-main.yml" + ], + "template_values": { + "source_branch": "develop", + "target_branch": "main", + "runner": "comcast-ubuntu-latest", + "secret_name": "SEMANTIC_RELEASE_TOKEN" + } + } + ] +} diff --git a/.github/workflows/sync-develop-to-main.yml b/.github/workflows/sync-develop-to-main.yml new file mode 100644 index 0000000..d208790 --- /dev/null +++ b/.github/workflows/sync-develop-to-main.yml @@ -0,0 +1,44 @@ +# Sync develop -> main -- consumer template +# +# Copy this file to .github/workflows/sync-develop-to-main.yml in any consumer repo. +# The sync logic lives in app-gateway-automation/actions/sync-branches. +# +# Optional secret: SEMANTIC_RELEASE_TOKEN (contents:write + pull-requests:write + issues:write) +# Used only for workflow_dispatch runs (falls back to github.token if absent). +# Push-triggered runs always use github.token to keep PAT scope out of routine automation. + +name: Sync develop to main + +on: + push: + branches: [develop] + workflow_dispatch: + inputs: + source_branch: + description: "Source branch" + required: false + default: "develop" + target_branch: + description: "Target branch" + required: false + default: "main" + +concurrency: + group: sync-${{ github.repository }}-${{ github.event_name == 'workflow_dispatch' && format('{0}-to-{1}', github.event.inputs.source_branch || 'develop', github.event.inputs.target_branch || 'main') || format('{0}-to-{1}', github.ref_name, 'main') }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + sync: + name: Merge ${{ github.event.inputs.source_branch || 'develop' }} → ${{ github.event.inputs.target_branch || 'main' }} + runs-on: comcast-ubuntu-latest # use org runner label; change if repo uses a different label + steps: + - uses: rdk-e/app-gateway-automation/actions/sync-branches@actions-v1 + with: + source_branch: ${{ github.event.inputs.source_branch || 'develop' }} + target_branch: ${{ github.event.inputs.target_branch || 'main' }} + token: ${{ github.event_name == 'workflow_dispatch' && (secrets.SEMANTIC_RELEASE_TOKEN || github.token) || github.token }} From 8caf806119323c3459a02f717f293adba4858712 Mon Sep 17 00:00:00 2001 From: bobra200 Date: Mon, 5 Oct 2026 10:01:32 -0700 Subject: [PATCH 2/5] chore(sync-branches): refresh automation artifacts to latest template --- .github/actions/sync-branches/README.md | 193 +++++- .github/actions/sync-branches/action.yml | 633 +++++++++++++++--- .../sync-branches/consumer-template.yml | 10 +- .github/workflows/sync-develop-to-main.yml | 8 +- 4 files changed, 711 insertions(+), 133 deletions(-) diff --git a/.github/actions/sync-branches/README.md b/.github/actions/sync-branches/README.md index 11ff251..92c9f80 100644 --- a/.github/actions/sync-branches/README.md +++ b/.github/actions/sync-branches/README.md @@ -6,34 +6,89 @@ Automated branch synchronization that keeps a target branch (for example `main`) - **Automatic triggers**: Runs on every push to source branch or manual dispatch - **Clean merges**: Direct push to target branch when no conflicts exist -- **Conflict handling**: Automatically opens a PR for manual resolution if conflicts occur +- **Conflict handling**: Opens or updates fallback PR and fallback issue artifacts for manual resolution - **Idempotent**: Skips sync if source is already in target (no unnecessary merges) - **Safer token policy**: Push-triggered runs use `github.token`; optional PAT is limited to manual dispatch +- **Policy-compliant fallback titles**: Fallback PR titles include a Jira key (derived or override) - **Customizable**: Configure branches, labels, reviewers, and commit identity -## Quick Start +## Consumption Modes + +This automation is intentionally distributed in two modes so internal (`rdk-e`) and OSS (`rdkcentral`) repositories can consume the same behavior using different delivery models. + +## Workflow Placement Rule + +Always install the workflow YAML at: +- `.github/workflows/sync-develop-to-main.yml` + +Do not place the workflow file under `.github/actions/`. +GitHub only auto-discovers workflow files in `.github/workflows/`. + +Naming note: +- The reusable action metadata file must remain `action.yml` inside `actions/sync-branches/`. +- `sync-develop-to-main.yml` is the consumer workflow filename under `.github/workflows/`. + +### Mode A: By Reference (RDK-E/Internal) + +How it works: +- Consumer workflow references the shared action directly: `uses: rdk-e/app-gateway-automation/actions/sync-branches@actions-v1`. +- The workflow file lives in the consumer repo, while action logic is executed from the shared release tag. + +Why this mode exists: +- Centralized ownership and rollout from one repository. +- No vendored action files in consumer repositories. +- Best fit for internal repositories that can access `rdk-e` and internal runners. + +Tradeoffs: +- Requires cross-repository access to `rdk-e/app-gateway-automation`. +- Behavior follows the moved major tag (`actions-v1`) release lifecycle. + +### Mode B: Install Mode (OSS/Vendored) + +How it works: +- Installer/wrapper copies managed files into the consumer repository. +- Consumer workflow calls the local action path: `uses: ./.github/actions/sync-branches`. + +Why this mode exists: +- Works when OSS repositories cannot consume private `rdk-e` references. +- Keeps automation self-contained and auditable in the target repository. +- Enables explicit, versioned upgrades via release bundle + lock file. + +Tradeoffs: +- Consumer repositories carry managed automation files. +- Upgrades require running installer/wrapper and merging the resulting change. + +## Mode A Quick Start (By Reference) ### 1. Copy The Consumer Template Copy `actions/sync-branches/consumer-template.yml` into your consumer repo as `.github/workflows/sync-develop-to-main.yml`. +Placement check: +- Correct: `.github/workflows/sync-develop-to-main.yml` +- Incorrect: `.github/actions/sync-develop-to-main.yml` + The default template uses: - `rdk-e/app-gateway-automation/actions/sync-branches@actions-v1` - `runs-on: comcast-ubuntu-latest` - `source_branch: develop` - `target_branch: main` -### 2. Optional: Add PAT Secret For Manual Dispatch +### 2. Optional: Add Token Secrets + +For RDK-E closed-source repos, use `RDKE_GITHUB_TOKEN` as the standard secret. +The template also supports `SEMANTIC_RELEASE_TOKEN` as a fallback. -If you want manual dispatch runs to use a PAT (for protected branch scenarios), add `SEMANTIC_RELEASE_TOKEN` in the consumer repo. +Token precedence in template workflows is: +1. `RDKE_GITHUB_TOKEN` +2. `SEMANTIC_RELEASE_TOKEN` +3. `github.token` Required PAT scopes: - `contents:write` - `pull-requests:write` - `issues:write` -Push-triggered runs still use `github.token` by design. - ### 3. Example Consumer Workflow ```yaml @@ -56,30 +111,51 @@ jobs: with: source_branch: ${{ github.event.inputs.source_branch || 'develop' }} target_branch: ${{ github.event.inputs.target_branch || 'main' }} - token: ${{ github.event_name == 'workflow_dispatch' && (secrets.SEMANTIC_RELEASE_TOKEN || github.token) || github.token }} + token: ${{ secrets.RDKE_GITHUB_TOKEN || secrets.SEMANTIC_RELEASE_TOKEN || github.token }} ``` ### 4. Optional: Generate From Installer You can generate a repo-specific workflow from `tools/install-workflow.sh`: +Default secret selection is automatic by target repo origin: +- `rdkcentral/*` repos: `SEMANTIC_RELEASE_TOKEN` +- other repos: `RDKE_GITHUB_TOKEN` +- `--secret-name` overrides either default + ```bash ./tools/install-workflow.sh \ --workflow sync-develop-to-main \ --repo-dir ../firebolt-entos-apis \ --source-branch develop \ --target-branch main \ - --secret-name SEMANTIC_RELEASE_TOKEN \ --runner comcast-ubuntu-latest \ --open-pr ``` This writes `.github/workflows/sync-develop-to-main.yml` in the target repo. -## OSS Bundle Installer (Manifest + Lock) +### 5. Single Command For Multiple RDK-E Consumer Repos + +```bash +SCRIPT=./tools/install-workflow.sh +for repo in ../firebolt-entos-apis ../firebolt-players ../firebolt-entos-runtime-apis; do + "$SCRIPT" --workflow sync-develop-to-main --repo-dir "$repo" --runner comcast-ubuntu-latest --open-pr +done +``` + +## Mode B Quick Start (Install Mode) For OSS repositories that cannot directly consume private reusable actions, use the bundle installer flow: +The bundle installer renders the workflow to use the local action path: + +- `uses: ./.github/actions/sync-branches` + +Important: +- The workflow file still belongs in `.github/workflows/`. +- Only the reusable action implementation lives in `.github/actions/sync-branches/`. + 1. Download release assets from `app-gateway-automation`: - `sync-branches-.tar.gz` - `sync-branches-.manifest.yaml` @@ -93,6 +169,8 @@ For OSS repositories that cannot directly consume private reusable actions, use --bundle /path/to/sync-branches-.tar.gz ``` +For `rdkcentral/*` repos, use `SEMANTIC_RELEASE_TOKEN` as the secret name (unless your bundle manifest specifies another default). + 3. Check current state: ```bash @@ -109,6 +187,14 @@ For OSS repositories that cannot directly consume private reusable actions, use --bundle /path/to/sync-branches-.tar.gz ``` +5. Optional: One-command OSS onboarding wrapper (install/update + branch + commit + PR): + +```bash +./tools/onboard-oss-sync.sh onboard \ + --repo-dir /path/to/oss-repo \ + --bundle /path/to/sync-branches-.tar.gz +``` + Managed files: - `.github/actions/sync-branches/` - `.github/workflows/sync-develop-to-main.yml` @@ -124,11 +210,13 @@ For fastest rollout, use this order: 1. Run `.github/workflows/release-actions.yml` on `main` to publish `actions-vX.Y.Z`. 2. Confirm the floating major tag (`actions-v1`) moved to the new release. -3. Install consumer workflow from template/installer (already pinned to `@actions-v1`). +3. Choose consumption mode per repo: + - Mode A (RDK-E): install consumer workflow pinned to `@actions-v1`. + - Mode B (OSS): install or update managed bundle files. 4. In each consumer repo, ensure permissions include `contents: write`, `pull-requests: write`, and `issues: write`. 5. Trigger `workflow_dispatch` once to validate token and branch settings. -## Usage Examples +## Usage Examples (Mode A By Reference) ### Example 1: Simple develop -> main sync @@ -169,7 +257,7 @@ jobs: pr_labels: "auto-merge,requires-review" git_user_name: "Release Bot" git_user_email: "releases@company.com" - token: ${{ secrets.SEMANTIC_RELEASE_TOKEN }} + token: ${{ secrets.RDKE_GITHUB_TOKEN }} ``` ## Inputs @@ -182,7 +270,13 @@ jobs: | `git_user_email` | `github-actions[bot]@users.noreply.github.com` | Committer email | | `pr_branch_prefix` | `auto-sync` | Prefix for fallback PR branches | | `assign_reviewers` | `` | Comma-separated team/user handles for PR assignment | -| `pr_labels` | `auto-sync,needs-review` | Comma-separated labels for fallback PR | +| `fallback_pr_jira_key` | `` | Optional Jira key override for fallback PR title (e.g. `RDKEMW-12345`) | +| `pr_labels` | `auto-sync,needs-manual-merge` | Comma-separated labels for fallback PR | +| `issue_labels` | `auto-sync,needs-manual-merge,needs-human-attention` | Comma-separated labels for fallback issue | +| `escalation_mentions` | `` | Optional mentions added to fallback issue body | +| `notification_webhook_url` | `` | Optional webhook URL for notifications (Slack-compatible incoming webhook) | +| `notification_on` | `human-intervention-required` | When to notify: `never`, `human-intervention-required`, or `all` | +| `fail_on_human_intervention` | `true` | If `true`, action exits non-zero when manual intervention is required. If `false`, action returns success and sets `result=human-intervention-required`. | ## Token Input @@ -205,6 +299,29 @@ attempt merge: success ✓ direct push to main: success ✓ ↓ ✅ main updated, no PR created + +``` + +### Example 4: Optional Slack/Webhook notifications + +```yaml +jobs: + sync: + runs-on: comcast-ubuntu-latest + steps: + - uses: rdk-e/app-gateway-automation/actions/sync-branches@actions-v1 + with: + source_branch: develop + target_branch: main + token: ${{ github.token }} + notification_webhook_url: ${{ secrets.SYNC_ALERT_WEBHOOK_URL }} + notification_on: human-intervention-required +``` + +Notes: +- The webhook payload is JSON: `{ "text": "..." }`, which works with Slack Incoming Webhooks and many webhook relays. +- If `notification_webhook_url` is not set, no notification is sent. +- Notification delivery is best-effort and non-blocking: webhook failures emit a warning but do not fail the sync workflow. ``` ### Fallback Case: Conflict or Protected Branch @@ -219,11 +336,25 @@ attempt merge: CONFLICT ✗ (or direct push fails due to protection) ↓ create branch: auto-sync/develop-to-main ↓ -open PR with labels & reviewers +open/update PR with labels & reviewers + ↓ +open/update issue with compare link + run link ↓ -⚠️ PR #123 created for manual resolution +⚠️ Human intervention required via PR/issue artifacts ``` +The fallback issue contains: +- Snapshot source commit SHA used for the fallback branch +- Fallback branch name to merge from +- Compare, run, and one-click PR links +- A manual merge checklist for responders + +When GitHub policy blocks PR creation by Actions token, the workflow also writes +an explicit warning and one-click PR URL to the job summary for faster manual recovery. + +By default this path fails the action (`result=human-intervention-required` + non-zero exit). +Set `fail_on_human_intervention: false` in the consumer workflow if you want a non-blocking run while still creating escalation artifacts. + ### Idempotent Case: Already Synced ``` push to develop (but develop already in main) @@ -252,15 +383,41 @@ Repository access: ## Troubleshooting +**Q: The run says source or target branch does not exist. What should I do?** +- For `workflow_dispatch`, verify the exact `source_branch` and `target_branch` inputs. +- For push-triggered runs, the branch may have been renamed/deleted after the event fired. +- List remote branches with: + +```bash +gh api repos///branches --jq '.[].name' +``` + +**Q: How do I test this locally with act?** +- Run act against the workflow file under `.github/workflows/` (not `.github/actions/`): + +```bash +GITHUB_TOKEN="$(gh auth token)" act workflow_dispatch \ + -W .github/workflows/sync-develop-to-main.yml \ + -P comcast-ubuntu-latest=ghcr.io/catthehacker/ubuntu:act-latest \ + --input source_branch=develop \ + --input target_branch=main +``` + +- If the run fails with `gh: command not found` during fallback PR/issue steps, use an act runner image that includes the GitHub CLI, or treat local testing as partial and validate fallback artifact creation in GitHub-hosted runners. + **Q: Workflow runs but push fails to protected branch** - Ensure your token has `contents:write`, `pull-requests:write`, and `issues:write` -- If you are using the template, verify `SEMANTIC_RELEASE_TOKEN` exists when you need PAT-backed dispatch -- Confirm the workflow expression passes `github.token` for push runs and optional PAT only for dispatch runs +- If you are using the template, verify `RDKE_GITHUB_TOKEN` exists (or `SEMANTIC_RELEASE_TOKEN` as fallback) +- Confirm the workflow expression is `secrets.RDKE_GITHUB_TOKEN || secrets.SEMANTIC_RELEASE_TOKEN || github.token` **Q: PR opens but I don't want it** - If it's just checking feature, use `workflow_dispatch` instead of automatic trigger - Adjust branch protection rules if conflicts are expected +**Q: Why did an issue open even when a fallback PR exists?** +- The action always upserts a fallback issue on human-intervention runs for consistent triage tracking. +- The issue body includes compare and run links plus current PR status. + **Q: I need to sync multiple branch pairs** - Create separate workflow files, each calling sync-branches with different `source_branch`/`target_branch` - Or use a matrix job in your calling workflow @@ -271,7 +428,7 @@ This integration guide is stored in `actions/sync-branches/` for easy discovery. The composite action is at `actions/sync-branches/action.yml`. -Versioned releases are published from `actions-vX.Y.Z` tags and include the action surface (`action.yml`, `consumer-template.yml`, `README.md`, `CHANGELOG.md`, and `scripts/`). +Versioned releases are published from `actions-vX.Y.Z` tags and include the action surface (`action.yml`, `consumer-template.yml`, `consumer-template-oss.yml`, `README.md`, `CHANGELOG.md`, and `scripts/`). --- diff --git a/.github/actions/sync-branches/action.yml b/.github/actions/sync-branches/action.yml index 194fa6a..9f3a30a 100644 --- a/.github/actions/sync-branches/action.yml +++ b/.github/actions/sync-branches/action.yml @@ -1,50 +1,77 @@ name: Sync Branches description: > Merge source branch into target branch and push directly. - On conflict or push failure, opens a fallback PR instead. - All logic is here — callers only need concurrency + permissions + triggers. + On conflict or push failure, open/update fallback artifacts for human intervention. + Callers only need triggers, concurrency, and permissions. inputs: source_branch: - description: "Branch to sync from" + description: Branch to sync from required: false - default: "develop" + default: develop target_branch: - description: "Branch to sync to" + description: Branch to sync to required: false - default: "main" + default: main token: - description: "PAT with contents:write + pull-requests:write + issues:write. Falls back to GITHUB_TOKEN then github.token." + description: Token for branch, PR, and issue operations. required: false default: "" git_user_name: - description: "Git committer name" + description: Git committer name required: false - default: "github-actions[bot]" + default: github-actions[bot] git_user_email: - description: "Git committer email" + description: Git committer email required: false - default: "github-actions[bot]@users.noreply.github.com" + default: github-actions[bot]@users.noreply.github.com pr_branch_prefix: - description: "Prefix for fallback PR branch" + description: Prefix for fallback PR branch required: false - default: "auto-sync" + default: auto-sync assign_reviewers: - description: "Comma-separated reviewers to assign to fallback PR" + description: Comma-separated reviewers for fallback PR + required: false + default: "" + fallback_pr_jira_key: + description: Optional Jira key override for fallback PR titles (for example RDKEMW-12345) required: false default: "" pr_labels: - description: "Comma-separated labels to apply to fallback PR" + description: Comma-separated labels for fallback PR + required: false + default: auto-sync,needs-manual-merge + issue_labels: + description: Comma-separated labels for fallback issue + required: false + default: auto-sync,needs-manual-merge,needs-human-attention + escalation_mentions: + description: Optional mentions to include in fallback issue (for example @org/team) + required: false + default: "" + notification_webhook_url: + description: Optional webhook URL for notifications (for example Slack incoming webhook). + required: false + default: "" + notification_on: + description: "When to send webhook notifications: never | human-intervention-required | all" + required: false + default: "human-intervention-required" + fail_on_human_intervention: + description: Whether to fail the action when manual intervention is required. required: false - default: "auto-sync,needs-manual-merge" + default: "true" outputs: result: - description: "Outcome: merged | skipped | pr-opened" + description: "Outcome: merged | skipped | human-intervention-required" value: ${{ steps.outcome.outputs.result }} pr_number: - description: "Fallback PR number (if opened)" - value: ${{ steps.outcome.outputs.pr_number }} + description: "Fallback PR number (if any)" + value: ${{ steps.pr_fallback.outputs.pr_number }} + issue_number: + description: "Fallback issue number (if any)" + value: ${{ steps.issue_fallback.outputs.issue_number }} runs: using: composite @@ -55,10 +82,10 @@ runs: env: INPUT_TOKEN: ${{ inputs.token }} run: | - if [ -n "${INPUT_TOKEN}" ]; then + if [[ -n "${INPUT_TOKEN}" ]]; then echo "token=${INPUT_TOKEN}" >> "$GITHUB_OUTPUT" echo "source=input:token" >> "$GITHUB_OUTPUT" - elif [ -n "${GITHUB_TOKEN}" ]; then + elif [[ -n "${GITHUB_TOKEN:-}" ]]; then echo "token=${GITHUB_TOKEN}" >> "$GITHUB_OUTPUT" echo "source=env:GITHUB_TOKEN" >> "$GITHUB_OUTPUT" else @@ -66,28 +93,109 @@ runs: echo "source=implicit:github.token" >> "$GITHUB_OUTPUT" fi + - name: Resolve and validate branch inputs + id: branches + shell: bash + env: + INPUT_SOURCE_BRANCH: ${{ inputs.source_branch }} + INPUT_TARGET_BRANCH: ${{ inputs.target_branch }} + run: | + set -euo pipefail + + validate_branch() { + local branch="$1" + local label="$2" + + if [[ -z "$branch" ]]; then + echo "${label} branch must not be empty." >&2 + exit 1 + fi + + if ! git check-ref-format --branch "$branch" >/dev/null 2>&1; then + echo "Invalid ${label} branch '$branch'." >&2 + exit 1 + fi + } + + source_branch="$INPUT_SOURCE_BRANCH" + target_branch="$INPUT_TARGET_BRANCH" + + validate_branch "$source_branch" "source" + validate_branch "$target_branch" "target" + + echo "source_branch=${source_branch}" >> "$GITHUB_OUTPUT" + echo "target_branch=${target_branch}" >> "$GITHUB_OUTPUT" + - name: Checkout full history uses: actions/checkout@v4 with: token: ${{ steps.token.outputs.token }} fetch-depth: 0 + - name: Prepare sync workspace + id: workspace + shell: bash + env: + AUTH_TOKEN: ${{ steps.token.outputs.token }} + run: | + set -euo pipefail + workdir="$(mktemp -d)" + git clone . "$workdir" >/dev/null 2>&1 + git -C "$workdir" checkout --detach "$GITHUB_SHA" >/dev/null 2>&1 + git -C "$workdir" remote set-url origin "https://x-access-token:${AUTH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + echo "path=${workdir}" >> "$GITHUB_OUTPUT" + - name: Configure git identity shell: bash + env: + WORKDIR: ${{ steps.workspace.outputs.path }} + GIT_USER_NAME: ${{ inputs.git_user_name }} + GIT_USER_EMAIL: ${{ inputs.git_user_email }} run: | - git config user.name "${{ inputs.git_user_name }}" - git config user.email "${{ inputs.git_user_email }}" + set -euo pipefail + git -C "$WORKDIR" config user.name "$GIT_USER_NAME" + git -C "$WORKDIR" config user.email "$GIT_USER_EMAIL" - name: Check if sync is needed id: check shell: bash + env: + WORKDIR: ${{ steps.workspace.outputs.path }} + SOURCE_BRANCH: ${{ steps.branches.outputs.source_branch }} + TARGET_BRANCH: ${{ steps.branches.outputs.target_branch }} run: | - git fetch origin \ - "refs/heads/${{ inputs.source_branch }}:refs/remotes/origin/${{ inputs.source_branch }}" \ - "refs/heads/${{ inputs.target_branch }}:refs/remotes/origin/${{ inputs.target_branch }}" - if git merge-base --is-ancestor "origin/${{ inputs.source_branch }}" "origin/${{ inputs.target_branch }}"; then + set -euo pipefail + + branch_exists_remote() { + local branch="$1" + git -C "$WORKDIR" ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1 + } + + if ! branch_exists_remote "$SOURCE_BRANCH"; then + echo "Sync aborted: source branch '${SOURCE_BRANCH}' does not exist on origin (${GITHUB_REPOSITORY})." >&2 + echo "Tips:" >&2 + echo " - If this was a workflow_dispatch run, verify the source_branch input value." >&2 + echo " - If this was a push-triggered run, the branch may have been renamed or deleted after the event fired." >&2 + echo " - Verify branch names with: gh api repos/${GITHUB_REPOSITORY}/branches --jq '.[].name'" >&2 + exit 1 + fi + + if ! branch_exists_remote "$TARGET_BRANCH"; then + echo "Sync aborted: target branch '${TARGET_BRANCH}' does not exist on origin (${GITHUB_REPOSITORY})." >&2 + echo "Tips:" >&2 + echo " - Confirm target_branch input for workflow_dispatch runs." >&2 + echo " - Ensure the destination branch exists (for example 'main')." >&2 + echo " - Verify branch names with: gh api repos/${GITHUB_REPOSITORY}/branches --jq '.[].name'" >&2 + exit 1 + fi + + git -C "$WORKDIR" fetch origin \ + "refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" \ + "refs/heads/${TARGET_BRANCH}:refs/remotes/origin/${TARGET_BRANCH}" + + if git -C "$WORKDIR" merge-base --is-ancestor "origin/${SOURCE_BRANCH}" "origin/${TARGET_BRANCH}"; then echo "already_synced=true" >> "$GITHUB_OUTPUT" - echo "${{ inputs.target_branch }} already contains all commits from ${{ inputs.source_branch }} — nothing to do." + echo "${TARGET_BRANCH} already contains all commits from ${SOURCE_BRANCH}." else echo "already_synced=false" >> "$GITHUB_OUTPUT" fi @@ -96,39 +204,113 @@ runs: if: steps.check.outputs.already_synced == 'false' id: merge shell: bash + env: + WORKDIR: ${{ steps.workspace.outputs.path }} + SOURCE_BRANCH: ${{ steps.branches.outputs.source_branch }} + TARGET_BRANCH: ${{ steps.branches.outputs.target_branch }} run: | - git checkout -B "${{ inputs.target_branch }}" "origin/${{ inputs.target_branch }}" - if git merge --no-edit "origin/${{ inputs.source_branch }}"; then + set -euo pipefail + git -C "$WORKDIR" checkout -B "$TARGET_BRANCH" "origin/${TARGET_BRANCH}" + if git -C "$WORKDIR" merge --no-edit "origin/${SOURCE_BRANCH}"; then echo "conflict=false" >> "$GITHUB_OUTPUT" else echo "conflict=true" >> "$GITHUB_OUTPUT" - git merge --abort || true + if git -C "$WORKDIR" rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1; then + git -C "$WORKDIR" merge --abort || true + fi fi - name: Push merged result if: steps.check.outputs.already_synced == 'false' && steps.merge.outputs.conflict == 'false' id: push shell: bash + env: + WORKDIR: ${{ steps.workspace.outputs.path }} + SOURCE_BRANCH: ${{ steps.branches.outputs.source_branch }} + TARGET_BRANCH: ${{ steps.branches.outputs.target_branch }} run: | - if git push origin "${{ inputs.target_branch }}"; then + set -euo pipefail + if git -C "$WORKDIR" push origin "$TARGET_BRANCH"; then echo "push_failed=false" >> "$GITHUB_OUTPUT" - echo "✅ Pushed ${{ inputs.source_branch }} → ${{ inputs.target_branch }} successfully." + echo "already_synced_after_push_reject=false" >> "$GITHUB_OUTPUT" + echo "Merged and pushed ${SOURCE_BRANCH} to ${TARGET_BRANCH}." else - echo "push_failed=true" >> "$GITHUB_OUTPUT" - echo "Direct push failed (branch protection). Falling back to PR." + git -C "$WORKDIR" fetch origin "refs/heads/${TARGET_BRANCH}:refs/remotes/origin/${TARGET_BRANCH}" + if git -C "$WORKDIR" merge-base --is-ancestor "origin/${SOURCE_BRANCH}" "origin/${TARGET_BRANCH}"; then + echo "push_failed=false" >> "$GITHUB_OUTPUT" + echo "already_synced_after_push_reject=true" >> "$GITHUB_OUTPUT" + echo "Push was rejected because target advanced concurrently and already contains ${SOURCE_BRANCH}." + else + echo "push_failed=true" >> "$GITHUB_OUTPUT" + echo "already_synced_after_push_reject=false" >> "$GITHUB_OUTPUT" + echo "Direct push failed; preparing fallback branch and escalation artifacts." + fi fi + - name: Push fallback branch artifact + if: steps.check.outputs.already_synced == 'false' && (steps.merge.outputs.conflict == 'true' || steps.push.outputs.push_failed == 'true') + id: fallback_branch + shell: bash + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + WORKDIR: ${{ steps.workspace.outputs.path }} + SOURCE_BRANCH: ${{ steps.branches.outputs.source_branch }} + TARGET_BRANCH: ${{ steps.branches.outputs.target_branch }} + PR_BRANCH_PREFIX: ${{ inputs.pr_branch_prefix }} + CONFLICT: ${{ steps.merge.outputs.conflict }} + run: | + set -euo pipefail + branch_prefix="${PR_BRANCH_PREFIX}/${SOURCE_BRANCH}-to-${TARGET_BRANCH}" + source_sha="$(git -C "$WORKDIR" rev-parse "origin/${SOURCE_BRANCH}")" + + reason="merge conflict" + if [[ "$CONFLICT" != "true" ]]; then + reason="direct push failed" + fi + + pr_list_json="$(gh pr list --base "$TARGET_BRANCH" --state open --json headRefName 2>/dev/null || echo '[]')" + existing_head="$(echo "$pr_list_json" | jq -r --arg prefix "$branch_prefix" 'map(select(.headRefName | startswith($prefix))) | .[0].headRefName // empty')" + + if [[ -n "$existing_head" ]]; then + echo "Reusing existing fallback branch ${existing_head}." + echo "fallback_branch=${existing_head}" >> "$GITHUB_OUTPUT" + echo "source_sha=${source_sha}" >> "$GITHUB_OUTPUT" + echo "reason=${reason}" >> "$GITHUB_OUTPUT" + exit 0 + fi + + pr_branch="$branch_prefix" + if git -C "$WORKDIR" ls-remote --exit-code --heads origin "$pr_branch" >/dev/null 2>&1; then + pr_branch="${pr_branch}-${GITHUB_RUN_ID}" + fi + + git -C "$WORKDIR" checkout -B "$pr_branch" "$source_sha" + git -C "$WORKDIR" push origin "$pr_branch" + + echo "fallback_branch=${pr_branch}" >> "$GITHUB_OUTPUT" + echo "source_sha=${source_sha}" >> "$GITHUB_OUTPUT" + echo "reason=${reason}" >> "$GITHUB_OUTPUT" + - name: Ensure labels exist if: steps.check.outputs.already_synced == 'false' && (steps.merge.outputs.conflict == 'true' || steps.push.outputs.push_failed == 'true') shell: bash env: GH_TOKEN: ${{ steps.token.outputs.token }} + PR_LABELS: ${{ inputs.pr_labels }} + ISSUE_LABELS: ${{ inputs.issue_labels }} run: | - IFS=',' read -ra LABELS <<< "${{ inputs.pr_labels }}" - for label in "${LABELS[@]}"; do - label=$(echo "$label" | xargs) - [ -n "$label" ] && gh label create "$label" --force || true - done + set -euo pipefail + ensure_labels() { + local csv="$1" + IFS=',' read -ra labels <<< "$csv" + for label in "${labels[@]}"; do + label="$(echo "$label" | xargs)" + [[ -n "$label" ]] && gh label create "$label" --force || true + done + } + + ensure_labels "$PR_LABELS" + ensure_labels "$ISSUE_LABELS" - name: Open or update fallback PR if: steps.check.outputs.already_synced == 'false' && (steps.merge.outputs.conflict == 'true' || steps.push.outputs.push_failed == 'true') @@ -136,90 +318,329 @@ runs: shell: bash env: GH_TOKEN: ${{ steps.token.outputs.token }} + SOURCE_BRANCH: ${{ steps.branches.outputs.source_branch }} + TARGET_BRANCH: ${{ steps.branches.outputs.target_branch }} + REASON: ${{ steps.fallback_branch.outputs.reason }} + FALLBACK_BRANCH: ${{ steps.fallback_branch.outputs.fallback_branch }} + PR_BRANCH_PREFIX: ${{ inputs.pr_branch_prefix }} + WORKDIR: ${{ steps.workspace.outputs.path }} + CONFLICT: ${{ steps.merge.outputs.conflict }} + FALLBACK_PR_JIRA_KEY: ${{ inputs.fallback_pr_jira_key }} + PR_LABELS: ${{ inputs.pr_labels }} + ASSIGN_REVIEWERS: ${{ inputs.assign_reviewers }} run: | - PR_BRANCH="${{ inputs.pr_branch_prefix }}/${{ inputs.source_branch }}-to-${{ inputs.target_branch }}" - REASON="merge conflict" - if [ "${{ steps.merge.outputs.conflict }}" != "true" ]; then - REASON="direct push failure (branch protection)" + set -euo pipefail + source_branch="$SOURCE_BRANCH" + target_branch="$TARGET_BRANCH" + reason="$REASON" + fallback_branch="$FALLBACK_BRANCH" + branch_prefix="${PR_BRANCH_PREFIX}/${source_branch}-to-${target_branch}" + workdir="$WORKDIR" + + extract_jira_key() { + local text="$1" + local key="" + key="$(echo "$text" | grep -oE '[A-Z][A-Z0-9]+-[0-9]+' | head -n1 || true)" + echo "$key" + } + + jira_key="" + jira_key_override="$FALLBACK_PR_JIRA_KEY" + if [[ -n "$jira_key_override" ]]; then + jira_key="$(extract_jira_key "$jira_key_override")" + if [[ -z "$jira_key" ]]; then + echo "fallback_pr_jira_key must include a Jira key like ABC-1234." >&2 + exit 1 + fi + fi + + if [[ -z "$jira_key" ]]; then + jira_key="$(extract_jira_key "$source_branch")" fi - EXISTING=$(gh pr list \ - --base "${{ inputs.target_branch }}" \ - --state open \ - --json number,headRefName 2>/dev/null | \ - jq -r --arg prefix "$PR_BRANCH" 'map(select(.headRefName | startswith($prefix))) | .[0].number // empty' || echo "") + if [[ -z "$jira_key" ]]; then + source_head_subject="$(git -C "$workdir" log -1 --pretty=%s "origin/${source_branch}" 2>/dev/null || true)" + jira_key="$(extract_jira_key "$source_head_subject")" + fi + + if [[ -z "$jira_key" ]]; then + jira_key="RDKDEV-0000" + echo "No Jira key detected from override, source branch, or source HEAD subject; using ${jira_key}." >&2 + fi - if [ -z "$EXISTING" ]; then - if git ls-remote --exit-code --heads origin "$PR_BRANCH" >/dev/null 2>&1; then - PR_BRANCH="${PR_BRANCH}-${GITHUB_RUN_ID}" + pr_list_json="$(gh pr list --base "$target_branch" --state open --json number,headRefName 2>/dev/null || echo '[]')" + existing_pr="$(echo "$pr_list_json" | jq -r --arg prefix "$branch_prefix" 'map(select(.headRefName | startswith($prefix))) | .[0].number // empty')" + existing_head="$(echo "$pr_list_json" | jq -r --arg prefix "$branch_prefix" 'map(select(.headRefName | startswith($prefix))) | .[0].headRefName // empty')" + + pr_title="${jira_key}: auto-sync ${source_branch} -> ${target_branch} (${reason})" + compare_branch="$fallback_branch" + if [[ -z "$compare_branch" && -n "$existing_head" ]]; then + compare_branch="$existing_head" + fi + compare_url="https://github.com/${GITHUB_REPOSITORY}/compare/${target_branch}...${compare_branch}?expand=1" + printf -v pr_body '%s\n\nReason: %s\nFallback branch: %s\nCompare: %s\nRun: %s' \ + "Automatic sync of ${source_branch} to ${target_branch} requires human intervention." \ + "$reason" \ + "${compare_branch}" \ + "$compare_url" \ + "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + + if [[ -n "$existing_pr" ]]; then + if [[ "$CONFLICT" != "true" && -n "$existing_head" ]]; then + git -C "$workdir" fetch origin "refs/heads/${existing_head}:refs/remotes/origin/${existing_head}" + git -C "$workdir" checkout -B "$existing_head" "origin/${source_branch}" + git -C "$workdir" push --force-with-lease origin "$existing_head" + echo "Updated fallback PR branch ${existing_head} to latest ${source_branch}." fi - git checkout -B "$PR_BRANCH" "origin/${{ inputs.source_branch }}" - git push origin "$PR_BRANCH" - - PR_CREATE_ARGS=( - "pr" "create" - "--base" "${{ inputs.target_branch }}" - "--head" "$PR_BRANCH" - "--title" "chore: auto-sync ${{ inputs.source_branch }} → ${{ inputs.target_branch }} (${REASON})" - "--body" "Automatic sync of \`${{ inputs.source_branch }}\` → \`${{ inputs.target_branch }}\` could not complete. **Action required:** resolve then merge. **Reason:** ${REASON}. Triggered by: ${{ github.sha }} on ${{ github.ref_name }}" - ) - - if [ -n "${{ inputs.pr_labels }}" ]; then - IFS=',' read -ra LABELS <<< "${{ inputs.pr_labels }}" - for label in "${LABELS[@]}"; do - label=$(echo "$label" | xargs) - [ -n "$label" ] && PR_CREATE_ARGS+=("--label" "$label") - done + if ! gh pr edit "$existing_pr" --title "$pr_title" --body "$pr_body" >/dev/null 2>&1; then + echo "Warning: failed to update title/body for fallback PR #${existing_pr}." >&2 fi + echo "pr_number=${existing_pr}" >> "$GITHUB_OUTPUT" + echo "pr_permission_denied=false" >> "$GITHUB_OUTPUT" + echo "pr_error_message=" >> "$GITHUB_OUTPUT" + exit 0 + fi - if [ -n "${{ inputs.assign_reviewers }}" ]; then - IFS=',' read -ra REVIEWERS <<< "${{ inputs.assign_reviewers }}" - for reviewer in "${REVIEWERS[@]}"; do - reviewer=$(echo "$reviewer" | xargs) - [ -n "$reviewer" ] && PR_CREATE_ARGS+=("--reviewer" "$reviewer") - done - fi + pr_create_args=( + pr create + --base "$target_branch" + --head "$fallback_branch" + --title "$pr_title" + --body "$pr_body" + ) - PR_URL=$(gh "${PR_CREATE_ARGS[@]}") - PR_NUM=$(gh pr view "$PR_URL" --json number --jq .number 2>/dev/null || echo "") - if [ -z "$PR_NUM" ]; then - PR_NUM=$(gh pr list \ - --base "${{ inputs.target_branch }}" \ - --head "$PR_BRANCH" \ - --state open \ - --json number --jq '.[0].number // empty' 2>/dev/null || echo "") - fi - if [ -z "$PR_NUM" ]; then - echo "Failed to resolve fallback PR number after creation." - exit 1 + IFS=',' read -ra labels <<< "$PR_LABELS" + for label in "${labels[@]}"; do + label="$(echo "$label" | xargs)" + [[ -n "$label" ]] && pr_create_args+=(--label "$label") + done + + IFS=',' read -ra reviewers <<< "$ASSIGN_REVIEWERS" + for reviewer in "${reviewers[@]}"; do + reviewer="$(echo "$reviewer" | xargs)" + [[ -n "$reviewer" ]] && pr_create_args+=(--reviewer "$reviewer") + done + + pr_create_output_file="$(mktemp)" + set +e + gh "${pr_create_args[@]}" >"$pr_create_output_file" 2>&1 + status=$? + set -e + + if [[ $status -eq 0 ]]; then + pr_url="$(cat "$pr_create_output_file")" + pr_number="$(gh pr view "$pr_url" --json number --jq .number 2>/dev/null || true)" + if [[ -z "$pr_number" ]]; then + pr_number="$(gh pr list --base "$target_branch" --head "$fallback_branch" --state open --json number --jq '.[0].number // empty' 2>/dev/null || true)" fi - echo "pr_number=${PR_NUM}" >> "$GITHUB_OUTPUT" - echo "PR #${PR_NUM} opened for manual resolution." - else - echo "pr_number=${EXISTING}" >> "$GITHUB_OUTPUT" - if [ "${{ steps.merge.outputs.conflict }}" != "true" ]; then - # Push-failure: update branch to latest source (no conflict resolution in progress) - UPDATE_BRANCH=$(gh pr view "$EXISTING" --json headRefName --jq .headRefName 2>/dev/null || echo "$PR_BRANCH") - git checkout -B "$UPDATE_BRANCH" "origin/${{ inputs.source_branch }}" - git push --force-with-lease origin "$UPDATE_BRANCH" - echo "PR #${EXISTING} branch updated to latest ${{ inputs.source_branch }} commits on $UPDATE_BRANCH." - else - echo "PR #${EXISTING} already open with merge conflict — leaving branch untouched." + if [[ -z "$pr_number" ]]; then + echo "Created PR but failed to resolve its number." >&2 + cat "$pr_create_output_file" >&2 + echo "pr_number=" >> "$GITHUB_OUTPUT" + echo "pr_permission_denied=false" >> "$GITHUB_OUTPUT" + echo "pr_error_message=Created PR but could not resolve PR number." >> "$GITHUB_OUTPUT" + exit 0 fi + echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT" + echo "pr_permission_denied=false" >> "$GITHUB_OUTPUT" + echo "pr_error_message=" >> "$GITHUB_OUTPUT" + exit 0 + fi + + create_err="$(cat "$pr_create_output_file")" + if echo "$create_err" | grep -Eq 'createPullRequest|not permitted to create or approve pull requests'; then + manual_pr_url="https://github.com/${GITHUB_REPOSITORY}/compare/${target_branch}...${fallback_branch}?expand=1&quick_pull=1" + echo "::warning::GitHub Actions token cannot create PRs in this repository policy. Open PR manually: ${manual_pr_url}" + { + echo "### PR Creation Blocked By Policy" + echo "- Reason: GitHub Actions token is not permitted to create/approve pull requests" + echo "- One-click PR: ${manual_pr_url}" + echo "- Fallback branch: ${fallback_branch}" + } >> "$GITHUB_STEP_SUMMARY" + echo "pr_number=" >> "$GITHUB_OUTPUT" + echo "pr_permission_denied=true" >> "$GITHUB_OUTPUT" + echo "pr_error_message=$(echo "$create_err" | tr '\n' ' ')" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "pr_number=" >> "$GITHUB_OUTPUT" + echo "pr_permission_denied=false" >> "$GITHUB_OUTPUT" + echo "pr_error_message=$(echo "$create_err" | tr '\n' ' ')" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Open or update fallback issue + if: steps.check.outputs.already_synced == 'false' && (steps.merge.outputs.conflict == 'true' || steps.push.outputs.push_failed == 'true') + id: issue_fallback + shell: bash + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + SOURCE_BRANCH: ${{ steps.branches.outputs.source_branch }} + TARGET_BRANCH: ${{ steps.branches.outputs.target_branch }} + SOURCE_SHA: ${{ steps.fallback_branch.outputs.source_sha }} + REASON: ${{ steps.fallback_branch.outputs.reason }} + FALLBACK_BRANCH: ${{ steps.fallback_branch.outputs.fallback_branch }} + PR_NUMBER: ${{ steps.pr_fallback.outputs.pr_number }} + PR_PERMISSION_DENIED: ${{ steps.pr_fallback.outputs.pr_permission_denied }} + PR_ERROR_MESSAGE: ${{ steps.pr_fallback.outputs.pr_error_message }} + ESCALATION_MENTIONS: ${{ inputs.escalation_mentions }} + ISSUE_LABELS: ${{ inputs.issue_labels }} + run: | + set -euo pipefail + source_branch="$SOURCE_BRANCH" + target_branch="$TARGET_BRANCH" + source_sha="$SOURCE_SHA" + reason="$REASON" + fallback_branch="$FALLBACK_BRANCH" + compare_url="https://github.com/${GITHUB_REPOSITORY}/compare/${target_branch}...${fallback_branch}?expand=1" + pr_create_url="https://github.com/${GITHUB_REPOSITORY}/compare/${target_branch}...${fallback_branch}?expand=1&quick_pull=1" + run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + + title="chore: auto-sync ${source_branch} -> ${target_branch} needs manual merge" + + pr_note="Fallback PR was updated or created successfully." + if [[ -n "$PR_NUMBER" ]]; then + pr_note="Fallback PR #${PR_NUMBER} is available for manual merge." + elif [[ "$PR_PERMISSION_DENIED" == "true" ]]; then + pr_note="Fallback PR creation was denied by token policy." + elif [[ -n "$PR_ERROR_MESSAGE" ]]; then + pr_note="Fallback PR creation encountered an error: ${PR_ERROR_MESSAGE}" + fi + + printf -v body '%s\n\nReason: %s\nSource branch: %s\nSource commit: %s\nFallback branch: %s\nCompare: %s\nCreate PR: %s\nRun: %s\n\nPR status: %s\n\nManual merge checklist:\n- Checkout `%s` and merge `%s` into it\n- Resolve conflicts\n- Open PR with one click (base `%s`): %s\n- Merge PR after checks pass\n- Close this issue' \ + "Automatic sync from ${source_branch} to ${target_branch} requires manual intervention." \ + "$reason" \ + "$source_branch" \ + "$source_sha" \ + "$fallback_branch" \ + "$compare_url" \ + "$pr_create_url" \ + "$run_url" \ + "$pr_note" \ + "$fallback_branch" \ + "$source_branch" \ + "$target_branch" \ + "$pr_create_url" + if [[ -n "$ESCALATION_MENTIONS" ]]; then + printf -v body '%s\n\nAttention: %s' "$body" "$ESCALATION_MENTIONS" + fi + + issue_list_json="$(gh issue list --state open --search "\"${title}\" in:title" --json number,title 2>/dev/null || echo '[]')" + existing_issue="$(echo "$issue_list_json" | jq -r --arg title "$title" 'map(select(.title == $title)) | .[0].number // empty')" + + if [[ -n "$existing_issue" ]]; then + gh issue edit "$existing_issue" --title "$title" --body "$body" + IFS=',' read -ra labels <<< "$ISSUE_LABELS" + for label in "${labels[@]}"; do + label="$(echo "$label" | xargs)" + [[ -n "$label" ]] && gh issue edit "$existing_issue" --add-label "$label" >/dev/null + done + echo "issue_number=${existing_issue}" >> "$GITHUB_OUTPUT" + exit 0 fi + issue_args=(issue create --title "$title" --body "$body") + IFS=',' read -ra labels <<< "$ISSUE_LABELS" + for label in "${labels[@]}"; do + label="$(echo "$label" | xargs)" + [[ -n "$label" ]] && issue_args+=(--label "$label") + done + + issue_url="$(gh "${issue_args[@]}")" + issue_number="$(gh issue view "$issue_url" --json number --jq .number 2>/dev/null || true)" + if [[ -z "$issue_number" ]]; then + issue_number="$(gh issue list --state open --search "\"${title}\" in:title" --json number,title | jq -r --arg title "$title" 'map(select(.title == $title)) | .[0].number // empty' 2>/dev/null || true)" + fi + if [[ -z "$issue_number" ]]; then + echo "Created issue but failed to resolve its number." >&2 + exit 1 + fi + echo "issue_number=${issue_number}" >> "$GITHUB_OUTPUT" + + - name: Cleanup sync workspace + if: always() + shell: bash + run: | + workdir="${{ steps.workspace.outputs.path }}" + [[ -n "$workdir" ]] && rm -rf "$workdir" + - name: Determine outcome id: outcome if: always() shell: bash + env: + FAIL_ON_HUMAN_INTERVENTION: ${{ inputs.fail_on_human_intervention }} run: | - if [ "${{ steps.check.outputs.already_synced }}" = "true" ]; then + if [[ "${{ steps.check.outputs.already_synced }}" == "true" ]]; then echo "result=skipped" >> "$GITHUB_OUTPUT" - elif [ "${{ steps.merge.outputs.conflict }}" = "false" ] && [ "${{ steps.push.outputs.push_failed }}" = "false" ]; then + exit 0 + fi + + if [[ "${{ steps.merge.outputs.conflict }}" == "false" && "${{ steps.push.outputs.push_failed }}" == "false" ]]; then + if [[ "${{ steps.push.outputs.already_synced_after_push_reject }}" == "true" ]]; then + echo "result=skipped" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "result=merged" >> "$GITHUB_OUTPUT" - else - echo "result=pr-opened" >> "$GITHUB_OUTPUT" - [ -n "${{ steps.pr_fallback.outputs.pr_number }}" ] && \ - echo "pr_number=${{ steps.pr_fallback.outputs.pr_number }}" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "result=human-intervention-required" >> "$GITHUB_OUTPUT" + normalized_fail="$(echo "${FAIL_ON_HUMAN_INTERVENTION}" | tr '[:upper:]' '[:lower:]')" + if [[ "$normalized_fail" == "true" || "$normalized_fail" == "1" || "$normalized_fail" == "yes" ]]; then exit 1 fi + + echo "Manual intervention required; continuing because fail_on_human_intervention is disabled." + exit 0 + + - name: Send optional webhook notification + if: always() && inputs.notification_webhook_url != '' + continue-on-error: true + shell: bash + env: + WEBHOOK_URL: ${{ inputs.notification_webhook_url }} + NOTIFICATION_ON: ${{ inputs.notification_on }} + RESULT: ${{ steps.outcome.outputs.result }} + SOURCE_BRANCH: ${{ steps.branches.outputs.source_branch }} + TARGET_BRANCH: ${{ steps.branches.outputs.target_branch }} + PR_NUMBER: ${{ steps.pr_fallback.outputs.pr_number }} + ISSUE_NUMBER: ${{ steps.issue_fallback.outputs.issue_number }} + run: | + set -euo pipefail + + result="${RESULT:-unknown}" + notify_on="$(echo "${NOTIFICATION_ON}" | tr '[:upper:]' '[:lower:]')" + + case "$notify_on" in + never) + exit 0 + ;; + human-intervention-required) + [[ "$result" == "human-intervention-required" ]] || exit 0 + ;; + all) + ;; + *) + echo "::warning::Invalid notification_on value '${NOTIFICATION_ON}'. Expected: never | human-intervention-required | all. Skipping notification." + exit 0 + ;; + esac + + run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + message="[${GITHUB_REPOSITORY}] sync ${SOURCE_BRANCH} -> ${TARGET_BRANCH}: ${result}. Run: ${run_url}" + + if [[ -n "${PR_NUMBER:-}" ]]; then + message+=" PR: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}" + fi + if [[ -n "${ISSUE_NUMBER:-}" ]]; then + message+=" Issue: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/issues/${ISSUE_NUMBER}" + fi + + payload="$(jq -n --arg text "$message" '{text: $text}')" + if ! curl -fsS --max-time 10 --retry 1 -X POST -H 'Content-Type: application/json' --data "$payload" "$WEBHOOK_URL" >/dev/null; then + echo "::warning::Notification webhook delivery failed; continuing without failing the workflow." + exit 0 + fi + + echo "Sent webhook notification for result '${result}'." diff --git a/.github/actions/sync-branches/consumer-template.yml b/.github/actions/sync-branches/consumer-template.yml index d208790..2f0483f 100644 --- a/.github/actions/sync-branches/consumer-template.yml +++ b/.github/actions/sync-branches/consumer-template.yml @@ -1,11 +1,13 @@ # Sync develop -> main -- consumer template # # Copy this file to .github/workflows/sync-develop-to-main.yml in any consumer repo. +# Do not place this file under .github/actions/; GitHub only loads workflow YAML from .github/workflows/. # The sync logic lives in app-gateway-automation/actions/sync-branches. # -# Optional secret: SEMANTIC_RELEASE_TOKEN (contents:write + pull-requests:write + issues:write) -# Used only for workflow_dispatch runs (falls back to github.token if absent). -# Push-triggered runs always use github.token to keep PAT scope out of routine automation. +# Optional secrets (first match wins): +# - RDKE_GITHUB_TOKEN +# - SEMANTIC_RELEASE_TOKEN +# Falls back to github.token if neither secret exists. name: Sync develop to main @@ -41,4 +43,4 @@ jobs: with: source_branch: ${{ github.event.inputs.source_branch || 'develop' }} target_branch: ${{ github.event.inputs.target_branch || 'main' }} - token: ${{ github.event_name == 'workflow_dispatch' && (secrets.SEMANTIC_RELEASE_TOKEN || github.token) || github.token }} + token: ${{ secrets.RDKE_GITHUB_TOKEN || secrets.SEMANTIC_RELEASE_TOKEN || github.token }} diff --git a/.github/workflows/sync-develop-to-main.yml b/.github/workflows/sync-develop-to-main.yml index d208790..1b72a99 100644 --- a/.github/workflows/sync-develop-to-main.yml +++ b/.github/workflows/sync-develop-to-main.yml @@ -1,11 +1,9 @@ # Sync develop -> main -- consumer template # -# Copy this file to .github/workflows/sync-develop-to-main.yml in any consumer repo. # The sync logic lives in app-gateway-automation/actions/sync-branches. # # Optional secret: SEMANTIC_RELEASE_TOKEN (contents:write + pull-requests:write + issues:write) -# Used only for workflow_dispatch runs (falls back to github.token if absent). -# Push-triggered runs always use github.token to keep PAT scope out of routine automation. +# Used for sync operations when present; falls back to github.token if absent. name: Sync develop to main @@ -35,10 +33,10 @@ permissions: jobs: sync: name: Merge ${{ github.event.inputs.source_branch || 'develop' }} → ${{ github.event.inputs.target_branch || 'main' }} - runs-on: comcast-ubuntu-latest # use org runner label; change if repo uses a different label + runs-on: comcast-ubuntu-latest steps: - uses: rdk-e/app-gateway-automation/actions/sync-branches@actions-v1 with: source_branch: ${{ github.event.inputs.source_branch || 'develop' }} target_branch: ${{ github.event.inputs.target_branch || 'main' }} - token: ${{ github.event_name == 'workflow_dispatch' && (secrets.SEMANTIC_RELEASE_TOKEN || github.token) || github.token }} + token: ${{ secrets.SEMANTIC_RELEASE_TOKEN || github.token }} From 976ad103b1abf310c6e367e0a445e9984efc1fe1 Mon Sep 17 00:00:00 2001 From: Brendan O'Bra Date: Mon, 5 Oct 2026 10:04:11 -0700 Subject: [PATCH 3/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/actions/sync-branches/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/sync-branches/README.md b/.github/actions/sync-branches/README.md index 92c9f80..c1a8b8e 100644 --- a/.github/actions/sync-branches/README.md +++ b/.github/actions/sync-branches/README.md @@ -102,7 +102,7 @@ on: permissions: contents: write pull-requests: write - + issues: write jobs: sync: runs-on: comcast-ubuntu-latest From 0faefd0fe105574ebb827f8ee2528772e2dd9d36 Mon Sep 17 00:00:00 2001 From: bobra200 Date: Mon, 5 Oct 2026 10:05:30 -0700 Subject: [PATCH 4/5] fix(sync-workflow): address PR security feedback --- .github/actions/sync-branches/action.yml | 10 +++++++--- .github/workflows/sync-develop-to-main.yml | 4 +++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/actions/sync-branches/action.yml b/.github/actions/sync-branches/action.yml index 9f3a30a..9be439c 100644 --- a/.github/actions/sync-branches/action.yml +++ b/.github/actions/sync-branches/action.yml @@ -568,15 +568,19 @@ runs: if: always() shell: bash env: + ALREADY_SYNCED: ${{ steps.check.outputs.already_synced }} + MERGE_CONFLICT: ${{ steps.merge.outputs.conflict }} + PUSH_FAILED: ${{ steps.push.outputs.push_failed }} + ALREADY_SYNCED_AFTER_PUSH_REJECT: ${{ steps.push.outputs.already_synced_after_push_reject }} FAIL_ON_HUMAN_INTERVENTION: ${{ inputs.fail_on_human_intervention }} run: | - if [[ "${{ steps.check.outputs.already_synced }}" == "true" ]]; then + if [[ "$ALREADY_SYNCED" == "true" ]]; then echo "result=skipped" >> "$GITHUB_OUTPUT" exit 0 fi - if [[ "${{ steps.merge.outputs.conflict }}" == "false" && "${{ steps.push.outputs.push_failed }}" == "false" ]]; then - if [[ "${{ steps.push.outputs.already_synced_after_push_reject }}" == "true" ]]; then + if [[ "$MERGE_CONFLICT" == "false" && "$PUSH_FAILED" == "false" ]]; then + if [[ "$ALREADY_SYNCED_AFTER_PUSH_REJECT" == "true" ]]; then echo "result=skipped" >> "$GITHUB_OUTPUT" exit 0 fi diff --git a/.github/workflows/sync-develop-to-main.yml b/.github/workflows/sync-develop-to-main.yml index 1b72a99..8d27743 100644 --- a/.github/workflows/sync-develop-to-main.yml +++ b/.github/workflows/sync-develop-to-main.yml @@ -35,7 +35,9 @@ jobs: name: Merge ${{ github.event.inputs.source_branch || 'develop' }} → ${{ github.event.inputs.target_branch || 'main' }} runs-on: comcast-ubuntu-latest steps: - - uses: rdk-e/app-gateway-automation/actions/sync-branches@actions-v1 + - name: Checkout repository + uses: actions/checkout@v4 + - uses: ./.github/actions/sync-branches with: source_branch: ${{ github.event.inputs.source_branch || 'develop' }} target_branch: ${{ github.event.inputs.target_branch || 'main' }} From cf8eacdf29171d562dffb740073b02ad3f1bcd26 Mon Sep 17 00:00:00 2001 From: bobra200 Date: Mon, 5 Oct 2026 10:14:21 -0700 Subject: [PATCH 5/5] fix(sync-branches): address follow-up review comments --- .github/actions/sync-branches/README.md | 4 +--- .github/actions/sync-branches/action.yml | 14 +++++++++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/actions/sync-branches/README.md b/.github/actions/sync-branches/README.md index c1a8b8e..8f5ed1d 100644 --- a/.github/actions/sync-branches/README.md +++ b/.github/actions/sync-branches/README.md @@ -8,7 +8,7 @@ Automated branch synchronization that keeps a target branch (for example `main`) - **Clean merges**: Direct push to target branch when no conflicts exist - **Conflict handling**: Opens or updates fallback PR and fallback issue artifacts for manual resolution - **Idempotent**: Skips sync if source is already in target (no unnecessary merges) -- **Safer token policy**: Push-triggered runs use `github.token`; optional PAT is limited to manual dispatch +- **Safer token policy**: Sync operations prefer the configured PAT and fall back to `github.token` - **Policy-compliant fallback titles**: Fallback PR titles include a Jira key (derived or override) - **Customizable**: Configure branches, labels, reviewers, and commit identity @@ -322,8 +322,6 @@ Notes: - The webhook payload is JSON: `{ "text": "..." }`, which works with Slack Incoming Webhooks and many webhook relays. - If `notification_webhook_url` is not set, no notification is sent. - Notification delivery is best-effort and non-blocking: webhook failures emit a warning but do not fail the sync workflow. -``` - ### Fallback Case: Conflict or Protected Branch ``` push to develop diff --git a/.github/actions/sync-branches/action.yml b/.github/actions/sync-branches/action.yml index 9be439c..c4d544c 100644 --- a/.github/actions/sync-branches/action.yml +++ b/.github/actions/sync-branches/action.yml @@ -268,7 +268,7 @@ runs: reason="direct push failed" fi - pr_list_json="$(gh pr list --base "$TARGET_BRANCH" --state open --json headRefName 2>/dev/null || echo '[]')" + pr_list_json="$(gh pr list --base "$TARGET_BRANCH" --state open --limit 200 --json headRefName 2>/dev/null || echo '[]')" existing_head="$(echo "$pr_list_json" | jq -r --arg prefix "$branch_prefix" 'map(select(.headRefName | startswith($prefix))) | .[0].headRefName // empty')" if [[ -n "$existing_head" ]]; then @@ -574,11 +574,23 @@ runs: ALREADY_SYNCED_AFTER_PUSH_REJECT: ${{ steps.push.outputs.already_synced_after_push_reject }} FAIL_ON_HUMAN_INTERVENTION: ${{ inputs.fail_on_human_intervention }} run: | + if [[ -z "${ALREADY_SYNCED:-}" ]]; then + echo "result=failed" >> "$GITHUB_OUTPUT" + echo "Unable to determine sync result because a prior step did not publish required outputs." >&2 + exit 1 + fi + if [[ "$ALREADY_SYNCED" == "true" ]]; then echo "result=skipped" >> "$GITHUB_OUTPUT" exit 0 fi + if [[ -z "${MERGE_CONFLICT:-}" || -z "${PUSH_FAILED:-}" ]]; then + echo "result=failed" >> "$GITHUB_OUTPUT" + echo "Unable to determine merge/push outcome from prior steps." >&2 + exit 1 + fi + if [[ "$MERGE_CONFLICT" == "false" && "$PUSH_FAILED" == "false" ]]; then if [[ "$ALREADY_SYNCED_AFTER_PUSH_REJECT" == "true" ]]; then echo "result=skipped" >> "$GITHUB_OUTPUT"