Outbound Bridge #1200
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Digital Service Orchestra — Outbound Bridge | |
| # Pushes local ticket changes to Jira when the tickets branch is updated. | |
| name: Outbound Bridge | |
| on: | |
| # Push trigger removed (71fa-c068): GitHub Actions does not detect workflow | |
| # files on orphan branches, so push-to-tickets never fired (all 100 recent | |
| # runs were workflow_dispatch). merge-to-main.sh dispatches explicitly. | |
| # | |
| # Scheduled trigger added (jira-dig-1575/1576): UI-merged PRs and direct | |
| # ticket-CLI mutations bypass merge-to-main.sh, leaving event backlogs on | |
| # the tickets branch indefinitely. The cron mirrors inbound-bridge.yml so | |
| # outbound is as reliable as inbound. concurrency: bridge-outbound prevents | |
| # storms when both an explicit dispatch and a cron tick land together | |
| # (separate group from inbound, per 5d93-8b62, so outbound and inbound | |
| # don't block each other). | |
| schedule: | |
| - cron: '*/30 * * * *' | |
| workflow_dispatch: | |
| inputs: | |
| backfill: | |
| description: 'Process all tickets (not just last commit). Use once to sync historical tickets.' | |
| required: false | |
| default: 'false' | |
| type: choice | |
| options: ['false', 'true'] | |
| concurrency: | |
| group: bridge-outbound | |
| cancel-in-progress: false | |
| jobs: | |
| bridge: | |
| name: Push tickets to Jira | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| permissions: | |
| contents: write | |
| # Skip commits made by the bridge bot itself (echo prevention). | |
| # Fall back to 'dso-bridge[bot]' when BRIDGE_BOT_LOGIN is unset so the | |
| # guard is never silently bypassed (unset var would evaluate to '' and | |
| # always pass, causing an infinite push loop on the tickets branch). | |
| if: github.actor != (vars.BRIDGE_BOT_LOGIN || 'dso-bridge[bot]') | |
| steps: | |
| - name: Checkout main (scripts and plugin code) | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 1 | |
| ref: main | |
| - name: Validate BRIDGE_ENV_ID | |
| run: | | |
| BRIDGE_ENV_ID="${{ vars.BRIDGE_ENV_ID }}" | |
| if [ -z "$BRIDGE_ENV_ID" ]; then | |
| echo "ERROR: BRIDGE_ENV_ID is unset or empty — cannot run outbound bridge. Set the BRIDGE_ENV_ID repository variable to a non-empty UUID: gh variable set BRIDGE_ENV_ID --body \"\$(uuidgen || python3 -c 'import uuid; print(uuid.uuid4())')\"" | |
| exit 1 | |
| fi | |
| - name: Mount tickets branch as worktree | |
| run: | | |
| git fetch origin tickets --deepen=50 | |
| rm -rf .tickets-tracker | |
| git worktree add .tickets-tracker tickets | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.x' | |
| - name: Cache ACLI binary | |
| id: cache-acli | |
| uses: actions/cache@v4 | |
| with: | |
| path: ~/.acli | |
| key: acli-${{ runner.os }}-${{ vars.ACLI_VERSION || 'latest' }} | |
| - name: Validate ACLI version before download | |
| if: steps.cache-acli.outputs.cache-hit != 'true' | |
| run: | | |
| ACLI_VERSION="${{ vars.ACLI_VERSION }}" | |
| if [ -z "$ACLI_VERSION" ] || [ "$ACLI_VERSION" = "latest" ]; then | |
| echo "ERROR: ACLI_VERSION is unset or 'latest' — cannot proceed with download." | |
| echo " Pin ACLI_VERSION to a specific release and set ACLI_SHA256 to its" | |
| echo " expected SHA256 digest before running this workflow." | |
| exit 1 | |
| fi | |
| - name: Download ACLI | |
| if: steps.cache-acli.outputs.cache-hit != 'true' | |
| run: | | |
| mkdir -p ~/.acli | |
| # ACLI v1.3+ is a Go binary distributed as tar.gz from acli.atlassian.com. | |
| curl -sSL "https://acli.atlassian.com/linux/${{ vars.ACLI_VERSION }}/acli_${{ vars.ACLI_VERSION }}_linux_amd64.tar.gz" \ | |
| -o ~/.acli/acli.tar.gz | |
| - name: Verify ACLI checksum | |
| if: steps.cache-acli.outputs.cache-hit != 'true' | |
| run: | | |
| ACLI_VERSION="${{ vars.ACLI_VERSION }}" | |
| ACLI_SHA256="${{ vars.ACLI_SHA256 }}" | |
| ARTIFACT=~/.acli/acli.tar.gz | |
| if [ -z "$ACLI_VERSION" ] || [ "$ACLI_VERSION" = "latest" ]; then | |
| echo "ERROR: ACLI_VERSION is unset or 'latest' — checksum verification cannot proceed." | |
| echo " Pin ACLI_VERSION to a specific release and set ACLI_SHA256 to its expected" | |
| echo " SHA256 digest before running this workflow." | |
| exit 1 | |
| elif [ -z "$ACLI_SHA256" ]; then | |
| COMPUTED_HASH=$(sha256sum "$ARTIFACT" | awk '{print $1}') | |
| echo "WARNING: ACLI_SHA256 is not set. Computed SHA256 of downloaded artifact:" | |
| echo "" | |
| echo " ACLI_SHA256=${COMPUTED_HASH}" | |
| echo "" | |
| echo "Set this value as a GitHub repository variable:" | |
| echo " gh variable set ACLI_SHA256 --body '${COMPUTED_HASH}'" | |
| echo "" | |
| echo "Proceeding WITHOUT checksum verification (first-run bootstrap)." | |
| else | |
| echo "${ACLI_SHA256} ${ARTIFACT}" | sha256sum -c --strict | |
| echo "Checksum verified: ${ARTIFACT}" | |
| fi | |
| - name: Extract ACLI binary | |
| if: steps.cache-acli.outputs.cache-hit != 'true' | |
| run: | | |
| # ACLI v1.3+ is a Go binary in a tar.gz with a version-prefixed directory. | |
| # --strip-components=1 removes the top-level dir (e.g., acli_1.3.14-stable_linux_amd64/). | |
| tar xzf ~/.acli/acli.tar.gz -C ~/.acli/ --strip-components=1 | |
| chmod +x ~/.acli/acli | |
| rm -f ~/.acli/acli.tar.gz | |
| echo "Extracted acli binary from tar.gz." | |
| - name: Add ACLI to PATH | |
| run: | | |
| if [ ! -f "$HOME/.acli/acli" ]; then | |
| echo "ERROR: $HOME/.acli/acli not found — download or extraction must have failed." >&2 | |
| exit 1 | |
| fi | |
| ln -sf "$HOME/.acli/acli" /usr/local/bin/acli | |
| - name: Authenticate ACLI | |
| run: | | |
| echo "$JIRA_API_TOKEN" | acli jira auth login \ | |
| --site "$JIRA_URL" \ | |
| --email "$JIRA_USER" \ | |
| --token | |
| env: | |
| JIRA_URL: ${{ vars.JIRA_URL }} | |
| JIRA_USER: ${{ vars.JIRA_USER }} | |
| JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} | |
| - name: Run outbound bridge | |
| id: run-bridge | |
| run: | | |
| BACKFILL_FLAG="" | |
| if [ "${{ inputs.backfill }}" = "true" ]; then | |
| BACKFILL_FLAG="--backfill" | |
| fi | |
| python3 plugins/dso/scripts/bridge-outbound.py $BACKFILL_FLAG | |
| env: | |
| JIRA_URL: ${{ vars.JIRA_URL }} | |
| JIRA_USER: ${{ vars.JIRA_USER }} | |
| JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} | |
| JIRA_PROJECT: ${{ vars.JIRA_PROJECT }} | |
| BRIDGE_ENV_ID: ${{ vars.BRIDGE_ENV_ID }} | |
| GH_RUN_ID: ${{ github.run_id }} | |
| - name: Commit SYNC events back to tickets branch | |
| run: | | |
| cd .tickets-tracker | |
| if [ -z "$(git status --porcelain)" ]; then | |
| echo "No SYNC events to commit — skipping." | |
| exit 0 | |
| fi | |
| # Configure bridge bot identity (not the default github-actions[bot]). | |
| # Values are read from env vars (set via the env: map below) rather than | |
| # interpolated directly into the run: block to prevent expression injection. | |
| git config user.name "${BRIDGE_BOT_NAME}" | |
| git config user.email "${BRIDGE_BOT_EMAIL}" | |
| git add -A | |
| git commit -m "chore: sync SYNC events from Jira bridge [run ${{ github.run_id }}]" | |
| # Push with 5-retry fetch-rebase-push loop with exponential backoff+jitter. | |
| # The tickets branch may be updated by merge-to-main.sh or ticket-lifecycle | |
| # during the bridge run, causing a non-fast-forward rejection. | |
| # Fixes 7e1c-ef09: added backoff, SHA-progress detection, stderr logging. | |
| # Pattern: ticket-lifecycle.sh Step 5 / mirrors inbound-bridge.yml. | |
| max_retries=5 | |
| attempt=0 | |
| while [ "$attempt" -lt "$max_retries" ]; do | |
| attempt=$((attempt + 1)) | |
| push_stderr="" | |
| push_exit=0 | |
| push_stderr=$(git push origin HEAD:tickets 2>&1) || push_exit=$? | |
| if [ "$push_exit" -eq 0 ]; then | |
| echo "Push succeeded (attempt $attempt)." | |
| exit 0 | |
| fi | |
| # Check if failure is retryable (non-fast-forward) vs fatal | |
| if echo "$push_stderr" | grep -qiE 'non-fast-forward|rejected|fetch first'; then | |
| printf 'Push rejected (attempt %d):\n%s\n' "$attempt" "$push_stderr" >&2 | |
| # Fetch — log stderr, do not swallow errors (7e1c-ef09 factor b) | |
| fetch_exit=0 | |
| git fetch origin tickets 2>&1 || fetch_exit=$? | |
| if [ "$fetch_exit" -ne 0 ]; then | |
| echo "Fetch failed (exit $fetch_exit) — retrying without updated remote" >&2 | |
| fi | |
| else | |
| echo "Push failed (non-retryable, exit $push_exit): $push_stderr" >&2 | |
| exit 1 | |
| fi | |
| # SHA-progress check: if rebase produces no new commit, retrying pushes the | |
| # same tip again and will be rejected the same way (7e1c-ef09 factor b). | |
| pre_sha=$(git rev-parse HEAD) | |
| rebase_exit=0 | |
| git rebase origin/tickets 2>&1 || rebase_exit=$? | |
| if [ "$rebase_exit" -ne 0 ]; then | |
| git rebase --abort 2>/dev/null || true | |
| echo "Rebase conflict during push retry (attempt $attempt)" >&2 | |
| exit 1 | |
| fi | |
| post_sha=$(git rev-parse HEAD) | |
| if [ "$pre_sha" = "$post_sha" ] && [ "$attempt" -lt "$max_retries" ]; then | |
| echo "Rebase produced no progress (origin/tickets unchanged) — re-fetching before next attempt" >&2 | |
| recovery_fetch_exit=0 | |
| git fetch origin tickets 2>&1 || recovery_fetch_exit=$? | |
| if [ "$recovery_fetch_exit" -ne 0 ]; then | |
| echo "Recovery fetch failed (exit $recovery_fetch_exit) — proceeding with stale remote ref" >&2 | |
| fi | |
| recovery_rebase_exit=0 | |
| git rebase origin/tickets 2>&1 || recovery_rebase_exit=$? | |
| if [ "$recovery_rebase_exit" -ne 0 ]; then | |
| git rebase --abort 2>/dev/null || true | |
| echo "Recovery rebase conflict — skipping backoff and retrying immediately" >&2 | |
| continue # skip backoff; next iteration will push (fail) then fetch+rebase again | |
| fi | |
| fi | |
| # Exponential backoff with jitter: 2^attempt + random(0-2)s (7e1c-ef09 factor a) | |
| if [ "$attempt" -lt "$max_retries" ]; then | |
| backoff=$(( (1 << attempt) + RANDOM % 3 )) | |
| echo "Waiting ${backoff}s before retry $((attempt + 1))..." >&2 | |
| sleep "$backoff" | |
| fi | |
| done | |
| echo "Push failed after $max_retries retries. Last push stderr: $push_stderr" >&2 | |
| exit 1 | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| BRIDGE_BOT_NAME: ${{ vars.BRIDGE_BOT_NAME || 'dso-bridge[bot]' }} | |
| BRIDGE_BOT_EMAIL: ${{ vars.BRIDGE_BOT_EMAIL || '[email protected]' }} | |
| - name: Job timing report | |
| if: always() | |
| run: echo "outbound-bridge completed at $(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| - name: Notify on bridge failure | |
| if: failure() | |
| env: | |
| GH_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| run: | | |
| { | |
| echo "## :rotating_light: Outbound Bridge run FAILED" | |
| echo "" | |
| echo "- **Workflow:** \`${{ github.workflow }}\`" | |
| echo "- **Run:** $GH_RUN_URL" | |
| echo "- **Run ID:** \`${{ github.run_id }}\`" | |
| echo "- **Triggered by:** \`${{ github.event_name }}\`" | |
| echo "- **Timestamp:** $(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| echo "" | |
| echo "Ticket tracking chronic silent-failure: \`7273-997c-9c4d-4e50\`" | |
| echo "" | |
| echo "Investigate via the run link above. If this is a recurring failure, check the Jira bridge logs and BRIDGE_ENV_ID / ACLI_VERSION repo variables." | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| echo "::error title=Outbound Bridge run failed::See run summary for details; tracker ticket 7273-997c-9c4d-4e50 — $GH_RUN_URL" |