Inbound Bridge #1768
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 — 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" |