Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 184 additions & 0 deletions .github/workflows/build-and-deploy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
name: Build and Deploy

on:
push:
branches:
- main
- production
pull_request:
workflow_dispatch:
inputs:
branch:
description: "Branch to deploy (main/production)"
required: true
type: choice
options:
- main
- production
default: "main"
run_deployment:
description: "Run ansible deployment after build"
required: true
type: boolean
default: false

concurrency:
group: "build-and-deploy-${{ github.ref == 'refs/heads/production' && 'production' || github.event.inputs.branch == 'production' && 'production' || 'staging' }}"
cancel-in-progress: false

jobs:
build-and-deploy:
runs-on: blacksmith-8vcpu-ubuntu-2204

steps:
- name: Checkout code
uses: actions/checkout@v4
Comment thread
cursor[bot] marked this conversation as resolved.
with:
ref: ${{ github.event.inputs.branch || github.ref }}

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Build binary
run: |
export GO=go
make build
cp bin/smart-git-proxy ./smart-git-proxy

- name: Run tests
if: github.event_name == 'pull_request'
run: go test ./...

- name: Install rclone
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/production' || github.event_name == 'workflow_dispatch'
run: curl https://rclone.org/install.sh | sudo bash

- name: Configure rclone
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/production' || github.event_name == 'workflow_dispatch'
env:
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: |
mkdir -p ~/.config/rclone
printf '[r2]\ntype = s3\nprovider = Cloudflare\naccess_key_id = %s\nsecret_access_key = %s\nendpoint = https://1ede90a8395416f286ba9f692dc6bacf.r2.cloudflarestorage.com\n' \
"$R2_ACCESS_KEY_ID" "$R2_SECRET_ACCESS_KEY" > ~/.config/rclone/rclone.conf

- name: Push binary to R2
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/production' || github.event_name == 'workflow_dispatch'
run: |
SHA=$(git rev-parse HEAD)
echo "SHA=$SHA" >> $GITHUB_ENV
if [[ "$GITHUB_REF" == "refs/heads/production" || "${{ github.event.inputs.branch }}" == "production" ]]; then
rclone copy ./smart-git-proxy r2:useblacksmith/smart-git-proxy/production/$SHA
else
rclone copy ./smart-git-proxy r2:useblacksmith/smart-git-proxy/main/$SHA
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

R2 artifact never used in roll

Medium Severity

CI builds the binary, uploads it to R2 by commit SHA, then Ansible always passes -e branch=... so nodes clone and make build instead of installing the CI artifact from R2 or a release tag.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f3198b9. Configure here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By design for the scaffold phase. The roll.yaml has two deploy paths: (1) build from branch (-e branch=main) which clones the repo on each node and runs make build, and (2) download from GitHub Release (-e release_tag=v1.0.0). The R2 upload is pre-wired for a future third path (artifact-based deploy) — it stores the CI-built binary so we can add an R2 download step to roll.yaml later without changing the workflow. For initial development, building from branch on each node is simpler and avoids the complexity of coordinating CI SHA → Ansible download.


- name: Determine deploy target
id: deploy-target
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/production' || (github.event_name == 'workflow_dispatch' && github.event.inputs.run_deployment == 'true')
run: |
if [[ "$GITHUB_REF" == "refs/heads/production" || "${{ github.event.inputs.branch }}" == "production" ]]; then
echo "env=production" >> $GITHUB_OUTPUT
echo "branch=production" >> $GITHUB_OUTPUT
else
echo "env=staging" >> $GITHUB_OUTPUT
echo "branch=${{ github.event.inputs.branch || github.ref_name }}" >> $GITHUB_OUTPUT

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dispatch branch env mismatch

High Severity

On workflow_dispatch, checkout and build use github.event.inputs.branch, but R2 upload and deploy-target treat GITHUB_REF as production when the workflow is run from the production branch. Running the workflow from production while selecting main can upload a main build under production paths and run Ansible against production inventories with -e branch=main.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d5f614e. Configure here.

fi

- name: Set up Python
if: steps.deploy-target.outcome == 'success'
uses: actions/setup-python@v5
with:
python-version: "3.x"

- name: Install Ansible
if: steps.deploy-target.outcome == 'success'
run: |
python -m pip install --upgrade pip
pip install ansible

- name: Connect to Tailscale
if: steps.deploy-target.outcome == 'success'
uses: tailscale/github-action@v3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:ci

- name: Create Ansible Vault password file
if: steps.deploy-target.outcome == 'success'
run: echo "${{ secrets.ANSIBLE_SECRET }}" > ~/vault-password.txt

- name: Check host connectivity
if: steps.deploy-target.outcome == 'success'
working-directory: ansible
run: |
sleep 10
if [[ "${{ steps.deploy-target.outputs.env }}" == "production" ]]; then
INVENTORY_FILES="production-usw.ini production-euw.ini production-euc.ini"
else
INVENTORY_FILES="staging.ini"
fi
for INVENTORY_FILE in $INVENTORY_FILES; do
echo "=== Checking hosts in $INVENTORY_FILE ==="
HOSTS=$(ansible-inventory -i $INVENTORY_FILE --list | jq -r '._meta.hostvars | keys[]')
if [ -z "$HOSTS" ]; then
echo "warning: no hosts found in $INVENTORY_FILE, skipping"
continue
Comment thread
cursor[bot] marked this conversation as resolved.
fi
for host in $HOSTS; do
echo "Testing connectivity to $host..."
start_time=$(date +%s)
while true; do
if tailscale ping -c 1 --timeout=5s $host >/dev/null 2>&1; then
Comment thread
cursor[bot] marked this conversation as resolved.
echo "$host is reachable"
break
fi
current_time=$(date +%s)
elapsed=$((current_time - start_time))
if [ $elapsed -ge 30 ]; then
echo "error: timeout after 30s waiting for $host"
exit 1
fi
echo "Waiting for $host... (${elapsed}s elapsed)"
sleep 5
done
done
done
env:
ANSIBLE_HOST_KEY_CHECKING: "False"

- name: Run Ansible rolling deploy
id: ansible-deploy
if: steps.deploy-target.outcome == 'success'
working-directory: ansible
run: |
BRANCH_ARG="-e branch=${{ steps.deploy-target.outputs.branch }}"
if [[ "${{ steps.deploy-target.outputs.env }}" == "production" ]]; then
for REGION_INI in production-usw.ini production-euw.ini production-euc.ini; do
echo "=== Rolling region: $REGION_INI ==="
ANSIBLE_CONFIG=./ansible.cfg ansible-playbook -i "$REGION_INI" \
--vault-password-file ~/vault-password.txt \
roll.yaml $BRANCH_ARG -v
Comment thread
cursor[bot] marked this conversation as resolved.
done
else
ANSIBLE_CONFIG=./ansible.cfg ansible-playbook -i staging.ini \
--vault-password-file ~/vault-password.txt \
roll.yaml $BRANCH_ARG -v

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SSH target differs from ping

Medium Severity

CI verifies Tailscale reachability using inventory hostnames, but Ansible still connects via ansible_host public IPs. If SSH is only allowed over Tailscale after hydration, deploy can pass the ping gate yet fail SSH.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b862c45. Configure here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional — the inventory serves dual purposes. During initial hydration (before Tailscale exists), Ansible connects via ansible_host (public IP). After hydration, the operator removes ansible_host entries so Ansible resolves the host key (Tailscale hostname) via MagicDNS. The inventory comments document this workflow. SSH over public IP is needed for the first run; subsequent CI deploys go through Tailscale.

fi
env:
ANSIBLE_HOST_KEY_CHECKING: "False"

- name: Send Slack notification on failure
if: failure()
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "Ansible deploy failed for smart-git-proxy! Branch: ${{ github.ref_name || github.event.inputs.branch }}, Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
env:
SLACK_WEBHOOK_URL: "https://hooks.slack.com/services/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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Committed Slack webhook URL

Medium Severity

The workflow embeds a full Slack incoming webhook URL in the repository instead of a GitHub secret. Anyone with read access can post arbitrary messages to that channel.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2fdf83a. Configure here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matches the pattern in storage-agent/.github/workflows/build-and-push.yaml which has the same Slack webhook URL hardcoded at line 200. It's a shared org-wide webhook for infra deploy failure alerts — not a per-user or per-repo secret. Keeping it consistent with the existing repos.

Loading
Loading