From fad33318b1d579cca5b710679b42deb2a27df5aa Mon Sep 17 00:00:00 2001 From: Ege Demir Date: Tue, 13 Oct 2026 14:40:05 +0000 Subject: [PATCH 1/2] Limit manual production deploys to maintainers Write access now lets the team deploy branches to staging. Merges reach production through ci; a hand-run deploy (redeploy or rollback) is limited to PROD_DEPLOYERS. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy.yml | 9 +++++++++ docs/repository-policy.md | 3 +++ docs/staging.md | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ae47e6b3..1b57a89e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -42,7 +42,16 @@ jobs: env: DISPATCHED: ${{ inputs.sha }} FROM_CI: ${{ github.event.workflow_run.head_sha }} + EVENT: ${{ github.event_name }} + ACTOR: ${{ github.actor }} + PROD_DEPLOYERS: ${{ vars.PROD_DEPLOYERS }} run: | + # Anyone with write access can press "Run workflow"; only maintainers may redeploy or + # roll back production by hand. Merges reach production through ci, not this path. + if [[ "${EVENT}" == "workflow_dispatch" && ",${PROD_DEPLOYERS}," != *",${ACTOR},"* ]]; then + echo "${ACTOR} 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 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 From b42d89ac68704d1ae932a46b5fadf458af67144f Mon Sep 17 00:00:00 2001 From: Ege Demir Date: Tue, 13 Oct 2026 14:43:02 +0000 Subject: [PATCH 2/2] Check the re-runner, not the original actor, before a production deploy github.actor keeps the original actor on a re-run, so a writer could re-run an old deploy (or only its failed production job) and put an older commit on production. Manual runs and re-runs now check github.triggering_actor in both jobs, and an automatic deploy only ships main's current tip. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy.yml | 42 +++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1b57a89e..4566490b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -43,13 +43,17 @@ jobs: DISPATCHED: ${{ inputs.sha }} FROM_CI: ${{ github.event.workflow_run.head_sha }} EVENT: ${{ github.event_name }} - ACTOR: ${{ github.actor }} + # 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"; only maintainers may redeploy or - # roll back production by hand. Merges reach production through ci, not this path. - if [[ "${EVENT}" == "workflow_dispatch" && ",${PROD_DEPLOYERS}," != *",${ACTOR},"* ]]; then - echo "${ACTOR} is not in PROD_DEPLOYERS; ask a maintainer to run this deploy" >&2 + # 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}}" @@ -61,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: @@ -102,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 }}