diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ae47e6b3..4566490b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -42,7 +42,20 @@ jobs: 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 @@ -52,10 +65,17 @@ jobs: 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: @@ -93,6 +113,27 @@ jobs: contents: read id-token: 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 + 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 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.release.outputs.sha }} diff --git a/docs/repository-policy.md b/docs/repository-policy.md index 54ea6096..6bbce7d0 100644 --- a/docs/repository-policy.md +++ b/docs/repository-policy.md @@ -67,6 +67,9 @@ See [GitHub's fork approval guidance](https://docs.github.com/en/actions/how-tos - Require the verify and staging checks, one code-owner review and resolved review threads on main. +- Let only `tin-lite-maintainers` update `main` ("Main: only maintainers merge"); other + writers open pull requests and deploy branches to staging. +- Limit manual runs of `deploy` to `PROD_DEPLOYERS`. - Restrict the `production` environment to `main`; Google Cloud trusts only that environment for the production deployer, and only `staging` for the staging deployer. - Limit the review/check bypass to the named Tin maintainer team. diff --git a/docs/staging.md b/docs/staging.md index 2c5553ca..c0840b98 100644 --- a/docs/staging.md +++ b/docs/staging.md @@ -26,6 +26,12 @@ Markdown) pass without a deploy. Staging has one slot: whoever deployed last own the next deploy, and a merge to `main` also takes it, so say in the team channel when you are testing there. +Anyone with write access can deploy to staging this way, without approval. Only the +`tin-lite-maintainers` team can update `main` (the "Main: only maintainers merge" ruleset), +and only the people in the `PROD_DEPLOYERS` repository variable can run **deploy** by hand, +so write access never reaches production on its own. Staging's host holds keys shared with +production, so write access is for the team. + ## From merge to production When `ci` passes on `main`, the `deploy` workflow deploys that commit to staging and, if it