diff --git a/.github/workflows/deploy-bot.yml b/.github/workflows/deploy-bot.yml new file mode 100644 index 0000000..27c2dcf --- /dev/null +++ b/.github/workflows/deploy-bot.yml @@ -0,0 +1,73 @@ +# Merging is deploying: a push to main that touches the bot or the checks it runs builds a +# new image and rolls it out to Cloud Run. Without this the hosted app silently drifts +# behind the code, which it did three times in one afternoon. +# +# Authentication is keyless: GitHub mints an OIDC token, Google trades it for a short-lived +# credential through Workload Identity Federation, and no service-account key exists to leak. +# The pool is bound to this repository only. +name: deploy-bot +on: + push: + branches: [main] + paths: + - "bot/**" + - "src/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/deploy-bot.yml" + workflow_dispatch: + +# A second push while the first is rolling out would race it to the same revision. +concurrency: + group: deploy-bot + cancel-in-progress: false + +permissions: + contents: read + id-token: write # required to mint the OIDC token + +env: + PROJECT: summer-ranger-205420 + REGION: us-central1 + SERVICE: polygo-bot + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: google-github-actions/auth@v2 + with: + workload_identity_provider: projects/987119925117/locations/global/workloadIdentityPools/github/providers/github + service_account: gh-deploy-bot@summer-ranger-205420.iam.gserviceaccount.com + + - uses: google-github-actions/setup-gcloud@v2 + + - name: Build the image + run: | + IMAGE="${REGION}-docker.pkg.dev/${PROJECT}/polygo/${SERVICE}:${GITHUB_SHA::7}" + echo "IMAGE=$IMAGE" >> "$GITHUB_ENV" + gcloud builds submit --config bot/cloudbuild.yaml \ + --substitutions="_IMAGE=${IMAGE}" --project "$PROJECT" --quiet . + + - name: Roll it out + run: | + gcloud run deploy "$SERVICE" --image "$IMAGE" --region "$REGION" \ + --project "$PROJECT" --quiet + + - name: Check it came back + run: | + URL=$(gcloud run services describe "$SERVICE" --region "$REGION" \ + --project "$PROJECT" --format='value(status.url)') + echo "deployed: $URL" + # A revision that cannot answer /health is a revision that cannot review a pull + # request; fail the job so the notification says so. + for i in $(seq 1 10); do + body=$(curl -fsS --max-time 20 "$URL/health" || true) + [ "$body" = "polygo-bot ok" ] && { echo "health: $body"; exit 0; } + sleep 5 + done + echo "the new revision never answered /health" >&2 + exit 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 663e2d0..2552e7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to polygo. Versions follow [SemVer](https://semver.org); dat ## Unreleased +- Merging is deploying: a push to `main` touching `bot/`, `src/` or the manifests builds the image and rolls out a Cloud Run revision, then fails the job if that revision cannot answer `/health`. Keyless — GitHub's OIDC token is exchanged through Workload Identity Federation for a short-lived credential, bound to this repository, so there is no service-account key to leak. The hosted app had already drifted behind the code three times in an afternoon of hand deploys. + - The GitHub App is [polygolint](https://github.com/apps/polygolint): `polygo` was taken by a dormant account, and app names share the username namespace. The bot comments as `polygolint[bot]`. The crate, binary, container and Cloud Run service keep the name `polygo-bot` — they are internal, and the service URL is wired into the app's webhook. - The README leads with the bot: what lands on a pull request, then the three ways to run it (install the app, four lines of CI, or the binary), each of them needing no configuration. `docs/img/review-comment-{light,dark}.svg` is the review as it appears on a pull request. diff --git a/bot/README.md b/bot/README.md index 99e6a72..1a8867a 100644 --- a/bot/README.md +++ b/bot/README.md @@ -57,6 +57,22 @@ The flags that matter, and why: seconds then shows in the delivery log as timed out; the review is still posted, because the platform lets the handler finish after GitHub hangs up. +## Deploying a change + +A push to `main` that touches `bot/`, `src/` or the manifests builds and rolls out a new +revision (`.github/workflows/deploy-bot.yml`), then fails the job if the new revision cannot +answer `/health`. Authentication is keyless — GitHub's OIDC token is exchanged through +Workload Identity Federation for a short-lived credential, and the pool is bound to this +repository — so no service-account key exists to leak. + +To deploy by hand anyway (a rollback, or from a branch): + +```sh +IMAGE=us-central1-docker.pkg.dev/summer-ranger-205420/polygo/polygo-bot:$(git rev-parse --short HEAD) +gcloud builds submit --config bot/cloudbuild.yaml --substitutions=_IMAGE=$IMAGE . +gcloud run deploy polygo-bot --image $IMAGE --region us-central1 +``` + ## Run it anywhere else ```sh diff --git a/tests/release_layout.rs b/tests/release_layout.rs index d8980a0..1836e6c 100644 --- a/tests/release_layout.rs +++ b/tests/release_layout.rs @@ -63,6 +63,15 @@ fn release_assets_match_every_consumer() { // A job that actually loads action/action.yml: a manifest GitHub cannot parse fails // every job that uses the Action, and nothing else in CI reads it. assert!(ci.contains("uses: ./action"), "{ci}"); + // The hosted app is deployed by merging, not by remembering to: keyless, and the + // rollout fails if the new revision cannot answer /health. + let deploy = read(".github/workflows/deploy-bot.yml"); + assert!(deploy.contains("workload_identity_provider"), "{deploy}"); + assert!( + !deploy.contains("credentials_json"), + "no service-account key belongs here" + ); + assert!(deploy.contains("/health"), "{deploy}"); // The formula is generated, not checked in. assert!(!std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/packaging")).exists()); }