Skip to content

deploy

deploy #122

Workflow file for this run

name: deploy
# A merge to main reaches production with no manual step: once ci passes on main, the same
# commit deploys to staging, and only if staging starts cleanly does it deploy to production.
# Running this workflow by hand redeploys any commit on main (for example, to roll back).
on:
workflow_run:
workflows: [ci]
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
sha:
description: Full SHA of a commit on main to deploy to staging and production
required: true
permissions:
contents: read
# Newer merges include older ones: a waiting run is replaced by the newest, never interrupted.
concurrency:
group: deploy-main
cancel-in-progress: false
jobs:
release:
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push')
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
sha: ${{ steps.pick.outputs.sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
fetch-depth: 0
persist-credentials: false
- id: pick
env:
DISPATCHED: ${{ inputs.sha }}
FROM_CI: ${{ github.event.workflow_run.head_sha }}
EVENT: ${{ github.event_name }}
# The person who pressed Run or Re-run; github.actor keeps the original actor on a
# re-run, so it cannot be what this checks.
TRIGGERED_BY: ${{ github.triggering_actor }}
ATTEMPT: ${{ github.run_attempt }}
PROD_DEPLOYERS: ${{ vars.PROD_DEPLOYERS }}
run: |
# Anyone with write access can press Run workflow or Re-run; only maintainers may
# start a production deploy by hand. Merges reach production through ci.
if [[ "${EVENT}" == "workflow_dispatch" || "${ATTEMPT}" != "1" ]] &&
[[ ",${PROD_DEPLOYERS}," != *",${TRIGGERED_BY},"* ]]; then
echo "${TRIGGERED_BY} is not in PROD_DEPLOYERS; ask a maintainer to run this deploy" >&2
exit 1
fi
sha="${DISPATCHED:-${FROM_CI}}"
if [[ ! "${sha}" =~ ^[0-9a-f]{40}$ ]]; then
echo "a full commit SHA is required" >&2
exit 1
fi
if ! git merge-base --is-ancestor "${sha}" origin/main; then
echo "${sha} is not on main" >&2
exit 1
fi
# An automatic deploy only ships main's current tip, so replaying an old run can't
# roll production back. Rolling back is a maintainer's Run workflow with the SHA.
if [[ "${EVENT}" == "workflow_run" && "${sha}" != "$(git rev-parse origin/main)" ]]; then
echo "::notice::${sha} is no longer main's tip; the newer merge deploys instead"
exit 0
fi
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
staging:
needs: release
if: needs.release.outputs.sha != ''
runs-on: ubuntu-latest
timeout-minutes: 45
environment:
name: staging
url: https://app.staging.tin.computer
permissions:
contents: read
id-token: write
statuses: write
steps:
- name: Report start
if: true
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
SHA: ${{ needs.release.outputs.sha }}
STATE: pending
DESCRIPTION: Deploying to staging…
TARGET_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
gh api --method POST "repos/${REPO}/statuses/${SHA}" -f state="${STATE}" \
-f context=deploy/staging -f description="${DESCRIPTION}" -f target_url="${TARGET_URL}" --silent
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release.outputs.sha }}
persist-credentials: false
- uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
with:
workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ vars.GCP_DEPLOY_SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1
- name: Deploy to staging
id: deploy
shell: bash
env:
TIN_LITE_DEPLOY_TARGET: staging
TIN_LITE_GCP_PROJECT: ${{ vars.GCP_PROJECT }}
TIN_LITE_STATIC_IP: ${{ vars.STAGING_STATIC_IP }}
run: bash infra/deploy.sh 2>&1 | tee "${RUNNER_TEMP}/deploy.log"
# A summary never fails a deploy.
- name: Summary
continue-on-error: true
if: always()
env:
SHA: ${{ needs.release.outputs.sha }}
OUTCOME: ${{ steps.deploy.outcome == 'success' && 'deployed' || 'failed' }}
run: bash .github/scripts/deploy-summary.sh staging "${SHA}" "${RUNNER_TEMP}/deploy.log" "${OUTCOME}"
- name: Report result
if: always()
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
SHA: ${{ needs.release.outputs.sha }}
STATE: ${{ steps.deploy.outcome == 'success' && 'success' || 'failure' }}
DESCRIPTION: ${{ steps.deploy.outcome == 'success' && 'Deployed to staging' || 'Staging deploy failed; production was not deployed' }}
TARGET_URL: ${{ steps.deploy.outcome == 'success' && 'https://app.staging.tin.computer' || format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }}
run: |
gh api --method POST "repos/${REPO}/statuses/${SHA}" -f state="${STATE}" \
-f context=deploy/staging -f description="${DESCRIPTION}" -f target_url="${TARGET_URL}" --silent
production:
needs: [release, staging]
runs-on: ubuntu-latest
timeout-minutes: 45
# Only main may use this environment; Google trusts only it for the production deployer.
environment:
name: production
url: https://app.tin.computer
permissions:
contents: read
id-token: write
statuses: write
steps:
# "Re-run failed jobs" reuses the release job's result, so this job checks again itself.
- name: Check who started this and that it is still main's tip
id: guard
env:
SHA: ${{ needs.release.outputs.sha }}
EVENT: ${{ github.event_name }}
TRIGGERED_BY: ${{ github.triggering_actor }}
ATTEMPT: ${{ github.run_attempt }}
PROD_DEPLOYERS: ${{ vars.PROD_DEPLOYERS }}
run: |
if [[ "${EVENT}" == "workflow_dispatch" || "${ATTEMPT}" != "1" ]] &&
[[ ",${PROD_DEPLOYERS}," != *",${TRIGGERED_BY},"* ]]; then
echo "${TRIGGERED_BY} is not in PROD_DEPLOYERS; ask a maintainer to run this deploy" >&2
exit 1
fi
if [[ "${EVENT}" == "workflow_run" ]]; then
tip="$(git ls-remote "https://github.com/${GITHUB_REPOSITORY}.git" refs/heads/main | cut -f1)"
if [[ "${SHA}" != "${tip}" ]]; then
echo "${SHA} is no longer main's tip (${tip}); not deploying it to production" >&2
exit 1
fi
fi
- name: Report start
if: steps.guard.outcome == 'success'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
SHA: ${{ needs.release.outputs.sha }}
STATE: ${{ (vars.PROD_AUTO_DEPLOY != 'false' || github.event_name == 'workflow_dispatch') && 'pending' || 'error' }}
DESCRIPTION: ${{ (vars.PROD_AUTO_DEPLOY != 'false' || github.event_name == 'workflow_dispatch') && 'Deploying to production…' || 'Paused because PROD_AUTO_DEPLOY is false' }}
TARGET_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
gh api --method POST "repos/${REPO}/statuses/${SHA}" -f state="${STATE}" \
-f context=deploy/production -f description="${DESCRIPTION}" -f target_url="${TARGET_URL}" --silent
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release.outputs.sha }}
persist-credentials: false
- uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
with:
workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ vars.GCP_DEPLOY_SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1
- name: Deploy to production
id: deploy
if: vars.PROD_AUTO_DEPLOY != 'false' || github.event_name == 'workflow_dispatch'
shell: bash
env:
TIN_LITE_DEPLOY_TARGET: prod
TIN_LITE_GCP_PROJECT: ${{ vars.GCP_PROJECT }}
TIN_LITE_STATIC_IP: ${{ vars.PRODUCTION_STATIC_IP }}
run: bash infra/deploy.sh 2>&1 | tee "${RUNNER_TEMP}/deploy.log"
# A summary never fails a deploy.
- name: Summary
continue-on-error: true
if: always() && steps.deploy.outcome != 'skipped'
env:
SHA: ${{ needs.release.outputs.sha }}
OUTCOME: ${{ steps.deploy.outcome == 'success' && 'deployed' || 'failed' }}
run: bash .github/scripts/deploy-summary.sh production "${SHA}" "${RUNNER_TEMP}/deploy.log" "${OUTCOME}"
- name: Report result
if: always() && steps.guard.outcome == 'success' && steps.deploy.outcome != 'skipped'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
SHA: ${{ needs.release.outputs.sha }}
STATE: ${{ steps.deploy.outcome == 'success' && 'success' || 'failure' }}
DESCRIPTION: ${{ steps.deploy.outcome == 'success' && 'Deployed to production' || 'Production deploy failed; see the run log' }}
TARGET_URL: ${{ steps.deploy.outcome == 'success' && 'https://app.tin.computer' || format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }}
run: |
gh api --method POST "repos/${REPO}/statuses/${SHA}" -f state="${STATE}" \
-f context=deploy/production -f description="${DESCRIPTION}" -f target_url="${TARGET_URL}" --silent
- name: Report a paused production deploy
if: vars.PROD_AUTO_DEPLOY == 'false' && github.event_name != 'workflow_dispatch'
run: echo "::warning::PROD_AUTO_DEPLOY is false; production was not deployed"