Skip to content

Inbound Bridge

Inbound Bridge #1767

# Digital Service Orchestra — Inbound Bridge
# Pulls Jira changes into local ticket system on a scheduled interval.
name: Inbound Bridge
on:
schedule:
- cron: '*/30 * * * *'
workflow_dispatch: {}
concurrency:
group: jira-bridge
cancel-in-progress: false
jobs:
bridge:
name: Pull tickets from 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 inbound 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 --depth=1
# Remove the placeholder .tickets-tracker/ from main (if it exists) # tickets-boundary-ok
rm -rf .tickets-tracker # tickets-boundary-ok
git worktree add .tickets-tracker tickets # tickets-boundary-ok
- 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: |
# Reject unset or unpinned version strings BEFORE any download occurs so
# no untrusted artifact is fetched with an interpolated/attacker-controlled
# version path.
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.
# The runner is linux/amd64 (ubuntu-latest).
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: |
# Verify the downloaded artifact BEFORE extraction so a compromised
# or MITM'd download cannot execute arbitrary code.
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
# Compute and log the hash so the operator can capture it from CI output
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: |
# ACLI Go binary requires explicit auth login (no env var auto-detection).
# Pipe the API token to stdin via --token flag.
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 inbound bridge
id: run-bridge
run: |
python3 plugins/dso/scripts/bridge-inbound.py
env:
JIRA_URL: ${{ vars.JIRA_URL }}
JIRA_USER: ${{ vars.JIRA_USER }}
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
BRIDGE_ENV_ID: ${{ vars.BRIDGE_ENV_ID }}
GH_RUN_ID: ${{ github.run_id }}
INBOUND_CHECKPOINT_PATH: ${{ vars.INBOUND_CHECKPOINT_PATH || '.tickets-tracker/.inbound-checkpoint.json' }} # tickets-boundary-ok
INBOUND_OVERLAP_BUFFER_MINUTES: ${{ vars.INBOUND_OVERLAP_BUFFER_MINUTES || '15' }}
INBOUND_STATUS_MAPPING: ${{ vars.INBOUND_STATUS_MAPPING || '{}' }}
INBOUND_TYPE_MAPPING: ${{ vars.INBOUND_TYPE_MAPPING || '{}' }}
JIRA_PROJECT: ${{ vars.JIRA_PROJECT }}
- name: Commit CREATE events back to tickets branch
run: |
cd .tickets-tracker # tickets-boundary-ok
# Check for any changes (staged, unstaged, or untracked)
if [ -z "$(git status --porcelain)" ]; then
echo "No new 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 CREATE events from Jira inbound bridge [run ${{ github.run_id }}]"
# Push with 5-retry fetch-rebase-push loop with exponential backoff+jitter.
# The tickets branch may be updated by outbound-bridge or merge-to-main.sh
# concurrently, causing a non-fast-forward rejection (d411-c0e7).
# Fixes 7e1c-ef09: added backoff, SHA-progress detection, stderr logging.
# Pattern mirrors outbound-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 || 'dso-bridge@users.noreply.github.com' }}
- name: Job timing report
if: always()
run: echo "inbound-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: Inbound 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=Inbound Bridge run failed::See run summary for details; tracker ticket 7273-997c-9c4d-4e50 — $GH_RUN_URL"