Workflows: production environment for every job with deploy secrets, Deploy only from main's tip, step-scoped Cloudflare secrets, pinned pnpm action, non-admin live-test token
- live-test (live), dind-probe (probe), d1-replication (check) and import-campaign (campaign) run in the production environment, so they keep their secrets once those move there. - deploy.yml: a gate job deploys only main's current tip, so re-running an old CI run can't roll production back (ACT-4). The Cloudflare secrets are on the steps that call Cloudflare only, not the job (SUP-4). - pnpm/action-setup pinned to v4.3.0's commit everywhere (SUP-4). - live-test: a missing token after a deploy is an error, not a silent green skip; the email check is skipped with a notice for a non-admin token (SUP-2). - import-campaign's relay targets codician/diffinery; the retired GitHub sync never runs (SUP-3). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014R3J2i3cD96PEceUzqjks8
8 files changed+93−22
Files (8)
modified.github/workflows/ci.yml+2−2View file
| @@ -24,7 +24,7 @@ | ||
| 24 | 24 | timeout-minutes: 20 |
| 25 | 25 | steps: |
| 26 | 26 | - uses: actions/checkout@v4 |
| 27 | - - uses: pnpm/action-setup@v4 | |
| 27 | + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 | |
| 28 | 28 | - uses: actions/setup-node@v4 |
| 29 | 29 | with: |
| 30 | 30 | node-version: 22 |
| @@ -49,7 +49,7 @@ | ||
| 49 | 49 | shard: [1, 2] |
| 50 | 50 | steps: |
| 51 | 51 | - uses: actions/checkout@v4 |
| 52 | - - uses: pnpm/action-setup@v4 | |
| 52 | + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 | |
| 53 | 53 | - uses: actions/setup-node@v4 |
| 54 | 54 | with: |
| 55 | 55 | node-version: 22 |
modified.github/workflows/d1-replication.yml+3−1View file
| @@ -19,6 +19,8 @@ | ||
| 19 | 19 | check: |
| 20 | 20 | name: Check sessions on a test database |
| 21 | 21 | runs-on: ubuntu-latest |
| 22 | + # The Cloudflare secrets are the production environment's. | |
| 23 | + environment: production | |
| 22 | 24 | timeout-minutes: 20 |
| 23 | 25 | env: |
| 24 | 26 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} |
| @@ -26,7 +28,7 @@ | ||
| 26 | 28 | NAME: diffinery-d1-probe-${{ github.run_id }} |
| 27 | 29 | steps: |
| 28 | 30 | - uses: actions/checkout@v4 |
| 29 | - - uses: pnpm/action-setup@v4 | |
| 31 | + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 | |
| 30 | 32 | - uses: actions/setup-node@v4 |
| 31 | 33 | with: |
| 32 | 34 | node-version: 22 |
modified.github/workflows/deploy.yml+56−5View file
| @@ -10,6 +10,11 @@ | ||
| 10 | 10 | # |
| 11 | 11 | # It deploys a main commit only once CI (typecheck, unit tests and the Worker bundle) has |
| 12 | 12 | # passed on it, so it doesn't run those itself. End-to-end tests run alongside the deploy. |
| 13 | +# Only main's current tip deploys: re-running an old CI run (which anyone with write access | |
| 14 | +# can do) doesn't bring back a version from before a fix. | |
| 15 | +# | |
| 16 | +# The Cloudflare secrets are the production environment's, and only the steps that talk to | |
| 17 | +# Cloudflare get them: installs, builds and third-party actions run without them. | |
| 13 | 18 | on: |
| 14 | 19 | workflow_run: |
| 15 | 20 | workflows: [CI] |
| @@ -25,13 +30,43 @@ | ||
| 25 | 30 | cancel-in-progress: false |
| 26 | 31 | |
| 27 | 32 | jobs: |
| 28 | - deploy: | |
| 29 | - name: Deploy to Cloudflare | |
| 33 | + gate: | |
| 34 | + name: Main's current tip | |
| 30 | 35 | # Production deploys from Diffinery itself, on its own hosted runners (any |
| 31 | 36 | # Diffinery, so self-hosted copies deploy themselves too). Running this |
| 32 | 37 | # workflow by hand on GitHub still deploys, as a fallback. |
| 33 | 38 | if: ${{ github.event_name == 'workflow_dispatch' || (github.server_url != 'https://github.com' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push') }} |
| 34 | 39 | runs-on: ubuntu-latest |
| 40 | + timeout-minutes: 5 | |
| 41 | + outputs: | |
| 42 | + deploy: ${{ steps.tip.outputs.deploy }} | |
| 43 | + steps: | |
| 44 | + - name: Deploy only main's current tip | |
| 45 | + id: tip | |
| 46 | + env: | |
| 47 | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| 48 | + WANT: ${{ github.event.workflow_run.head_sha || github.sha }} | |
| 49 | + REF: ${{ github.ref }} | |
| 50 | + run: | | |
| 51 | + set -euo pipefail | |
| 52 | + if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ] && [ "$REF" != refs/heads/main ]; then | |
| 53 | + echo "::error::Deploy runs from main only (this run is on $REF)"; exit 1 | |
| 54 | + fi | |
| 55 | + tip=$(curl -fsS "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/branches/main" -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" | jq -r .commit.sha) | |
| 56 | + if [ "$tip" = "$WANT" ]; then | |
| 57 | + echo "deploy=true" >> "$GITHUB_OUTPUT" | |
| 58 | + echo "deploying $WANT, main's tip" | |
| 59 | + else | |
| 60 | + # A later commit's CI run deploys it: a re-run of an old CI run deploys nothing. | |
| 61 | + echo "deploy=false" >> "$GITHUB_OUTPUT" | |
| 62 | + echo "::notice::not deploying $WANT: main is at $tip now, and that commit's own run deploys it" | |
| 63 | + fi | |
| 64 | + | |
| 65 | + deploy: | |
| 66 | + name: Deploy to Cloudflare | |
| 67 | + needs: gate | |
| 68 | + if: ${{ needs.gate.outputs.deploy == 'true' }} | |
| 69 | + runs-on: ubuntu-latest | |
| 35 | 70 | timeout-minutes: 45 |
| 36 | 71 | environment: |
| 37 | 72 | name: production |
| @@ -40,15 +75,13 @@ | ||
| 40 | 75 | DIFFINERY_DOMAIN: ${{ vars.DIFFINERY_DOMAIN || 'diffinery.com' }} |
| 41 | 76 | DIFFINERY_ZONE: ${{ vars.DIFFINERY_ZONE }} |
| 42 | 77 | MAIL_DOMAIN: ${{ vars.MAIL_DOMAIN }} |
| 43 | - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 44 | - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| 45 | 78 | ACCESS_TEAM_DOMAIN: ${{ vars.ACCESS_TEAM_DOMAIN }} |
| 46 | 79 | steps: |
| 47 | 80 | - uses: actions/checkout@v4 |
| 48 | 81 | with: |
| 49 | 82 | # The commit CI passed on, not whatever main is by now. |
| 50 | 83 | ref: ${{ github.event.workflow_run.head_sha || github.sha }} |
| 51 | - - uses: pnpm/action-setup@v4 | |
| 84 | + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 | |
| 52 | 85 | - uses: actions/setup-node@v4 |
| 53 | 86 | with: |
| 54 | 87 | node-version: 22 |
| @@ -57,27 +90,45 @@ | ||
| 57 | 90 | - name: Build runner agent and web islands |
| 58 | 91 | run: pnpm -r --if-present build |
| 59 | 92 | - name: Runner image (built only when the runner changed) |
| 93 | + env: | |
| 94 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 95 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| 60 | 96 | run: scripts/runner-image.sh |
| 61 | 97 | |
| 62 | 98 | - name: Create or update Cloudflare resources |
| 63 | 99 | id: bootstrap |
| 100 | + env: | |
| 101 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 102 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| 64 | 103 | run: scripts/bootstrap.sh |
| 65 | 104 | |
| 66 | 105 | - name: Deploy Worker and runner image |
| 67 | 106 | working-directory: apps/diffinery |
| 107 | + env: | |
| 108 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 109 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| 68 | 110 | run: pnpm exec wrangler deploy -c wrangler.deploy.jsonc |
| 69 | 111 | |
| 70 | 112 | - name: Wait for new containers to get the new image |
| 113 | + env: | |
| 114 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 115 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| 71 | 116 | # The Live test follows this workflow, and its checks must run on the new image. |
| 72 | 117 | # This job runs on an old-image container, so finishing after the rollout's last step |
| 73 | 118 | # also shows that a deploy doesn't stop a running job. |
| 74 | 119 | run: scripts/wait-container-rollout.sh |
| 75 | 120 | |
| 76 | 121 | - name: Worker secrets (generated on the first deploy; TURNSTILE_SECRET when missing) |
| 122 | + env: | |
| 123 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 124 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| 77 | 125 | run: scripts/ensure-secrets.sh |
| 78 | 126 | |
| 79 | 127 | - name: Finish Access setup on first deploy |
| 80 | 128 | if: steps.bootstrap.outputs.access_team == '' |
| 129 | + env: | |
| 130 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 131 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| 81 | 132 | run: | |
| 82 | 133 | scripts/bootstrap.sh |
| 83 | 134 | cd apps/diffinery && pnpm exec wrangler deploy -c wrangler.deploy.jsonc --containers-rollout none |
modified.github/workflows/dind-probe.yml+3−1View file
| @@ -14,6 +14,8 @@ | ||
| 14 | 14 | probe: |
| 15 | 15 | name: Probe Docker inside a container |
| 16 | 16 | runs-on: ubuntu-latest |
| 17 | + # The Cloudflare secrets are the production environment's. | |
| 18 | + environment: production | |
| 17 | 19 | timeout-minutes: 40 |
| 18 | 20 | env: |
| 19 | 21 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} |
| @@ -21,7 +23,7 @@ | ||
| 21 | 23 | NAME: diffinery-dind-probe-${{ github.run_id }} |
| 22 | 24 | steps: |
| 23 | 25 | - uses: actions/checkout@v4 |
| 24 | - - uses: pnpm/action-setup@v4 | |
| 26 | + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 | |
| 25 | 27 | - uses: actions/setup-node@v4 |
| 26 | 28 | with: |
| 27 | 29 | node-version: 22 |
modified.github/workflows/e2e.yml+1−1View file
| @@ -39,7 +39,7 @@ | ||
| 39 | 39 | shard: rest |
| 40 | 40 | steps: |
| 41 | 41 | - uses: actions/checkout@v4 |
| 42 | - - uses: pnpm/action-setup@v4 | |
| 42 | + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 | |
| 43 | 43 | - uses: actions/setup-node@v4 |
| 44 | 44 | with: |
| 45 | 45 | node-version: 22 |
modified.github/workflows/import-campaign.yml+3−1View file
| @@ -66,7 +66,7 @@ | ||
| 66 | 66 | env: |
| 67 | 67 | DIFFINERY: https://diffinery.com |
| 68 | 68 | TOKEN: ${{ secrets.DIFFINERY_LIVE_TOKEN }} |
| 69 | - R: codician-team/diffinery | |
| 69 | + R: codician/diffinery | |
| 70 | 70 | steps: |
| 71 | 71 | - name: Copy IMPORT_GITHUB_TOKEN to Diffinery |
| 72 | 72 | if: ${{ inputs.follow_run == '' && !inputs.peek && inputs.rerun_failed == '' && inputs.inspect_run == '' && inputs.push_test == '' }} |
| @@ -229,6 +229,8 @@ | ||
| 229 | 229 | if: ${{ github.server_url != 'https://github.com' }} |
| 230 | 230 | runs-on: ubuntu-latest |
| 231 | 231 | timeout-minutes: 360 |
| 232 | + # DIFFINERY_LIVE_TOKEN and IMPORT_GITHUB_TOKEN are the production environment's. | |
| 233 | + environment: production | |
| 232 | 234 | env: |
| 233 | 235 | DIFFINERY: ${{ github.server_url }} |
| 234 | 236 | TOKEN: ${{ secrets.DIFFINERY_LIVE_TOKEN }} |
modified.github/workflows/live-test.yml+18−5View file
| @@ -6,8 +6,10 @@ | ||
| 6 | 6 | # queue). Each run works in a throwaway private repository that it |
| 7 | 7 | # deletes at the end. |
| 8 | 8 | # |
| 9 | -# Needs the repository secret DIFFINERY_LIVE_TOKEN: a Diffinery token (Settings -> | |
| 10 | -# Tokens) with the admin scope and access to all repositories. | |
| 9 | +# Needs DIFFINERY_LIVE_TOKEN in the production environment: a write-scope token | |
| 10 | +# (Settings -> Tokens) with access to all repositories, for a dedicated account that isn't | |
| 11 | +# a site administrator (give it a quota override). The checks that need a site | |
| 12 | +# administrator are skipped with a notice when the token isn't one's. | |
| 11 | 13 | on: |
| 12 | 14 | workflow_dispatch: |
| 13 | 15 | workflow_run: |
| @@ -29,6 +31,7 @@ | ||
| 29 | 31 | if: ${{ github.event_name == 'workflow_dispatch' || (github.server_url == 'https://diffinery.com' && github.event.workflow_run.conclusion == 'success') }} |
| 30 | 32 | runs-on: ubuntu-latest |
| 31 | 33 | timeout-minutes: 45 |
| 34 | + environment: production | |
| 32 | 35 | env: |
| 33 | 36 | DIFFINERY: https://diffinery.com |
| 34 | 37 | TOKEN: ${{ secrets.DIFFINERY_LIVE_TOKEN }} |
| @@ -39,7 +42,12 @@ | ||
| 39 | 42 | id: token |
| 40 | 43 | run: | |
| 41 | 44 | if [ -z "$TOKEN" ]; then |
| 42 | - echo "::warning::DIFFINERY_LIVE_TOKEN is not set, so the live test is skipped. Create an admin token at $DIFFINERY/settings/tokens and save it as a repository secret." | |
| 45 | + # After a deploy of diffinery.com a missing token is an error: a green run would mean nothing was tested. | |
| 46 | + if [ "$GITHUB_EVENT_NAME" = workflow_run ]; then | |
| 47 | + echo "::error::DIFFINERY_LIVE_TOKEN is not set in the production environment, so nothing was tested. Create a write token at $DIFFINERY/settings/tokens and save it as a production environment secret." | |
| 48 | + exit 1 | |
| 49 | + fi | |
| 50 | + echo "::warning::DIFFINERY_LIVE_TOKEN is not set, so the live test is skipped. Create a write token at $DIFFINERY/settings/tokens and save it as a production environment secret." | |
| 43 | 51 | echo "skip=true" >> "$GITHUB_OUTPUT" |
| 44 | 52 | exit 0 |
| 45 | 53 | fi |
| @@ -145,7 +153,11 @@ | ||
| 145 | 153 | if: steps.token.outputs.skip != 'true' |
| 146 | 154 | run: | |
| 147 | 155 | set -euo pipefail |
| 148 | - s=$(curl -fsS "$DIFFINERY/api/v3/diffinery/admin/email" -H "Authorization: Bearer $TOKEN") | |
| 156 | + s=$(curl -sS -w '\n%{http_code}' "$DIFFINERY/api/v3/diffinery/admin/email" -H "Authorization: Bearer $TOKEN") | |
| 157 | + code=${s##*$'\n'}; s=${s%$'\n'*} | |
| 158 | + # Only a site administrator's token reads the email settings, and the live-test account isn't one. | |
| 159 | + if [ "$code" = 403 ]; then echo "::notice::skipped: the live-test token isn't a site administrator's"; exit 0; fi | |
| 160 | + [ "$code" = 200 ] || { echo "::error::/diffinery/admin/email answered $code"; exit 1; } | |
| 149 | 161 | echo "$s" | jq '{sending, queue, from, last_24h}' |
| 150 | 162 | [ "$(jq -r .sending <<<"$s")" = true ] || { echo "::error::the Worker has no Email Sending binding"; exit 1; } |
| 151 | 163 | [ "$(jq -r .queue <<<"$s")" = true ] || { echo "::error::the Worker has no mail queue binding"; exit 1; } |
| @@ -770,7 +782,8 @@ | ||
| 770 | 782 | sleep 10 |
| 771 | 783 | done |
| 772 | 784 | if [ -z "$run" ]; then |
| 773 | - # Whether the scheduler stopped ticking (last_tick_at is old) or ticked without starting the run. | |
| 785 | + # Whether the scheduler stopped ticking (last_tick_at is old) or ticked without starting the run | |
| 786 | + # (site administrators only: another token prints the 403). | |
| 774 | 787 | echo "scheduler: $(curl -sS "$DIFFINERY/api/v3/diffinery/admin/scheduler" -H "Authorization: Bearer $TOKEN") (now $(date -u +%FT%TZ))" |
| 775 | 788 | echo "::error::no scheduled run started" |
| 776 | 789 | exit 1 |
modified.github/workflows/sync-to-diffinery.yml+7−6View file
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | name: Sync to Diffinery |
| 2 | 2 | |
| 3 | -# Keeps diffinery.com/codician-team/diffinery in step with this repository: every | |
| 3 | +# Kept diffinery.com/codician-team/diffinery (now codician/diffinery) in step with this repository: every | |
| 4 | 4 | # branch and tag, plus the Actions secrets and variables our workflows use. On the |
| 5 | 5 | # first run it creates the organization and repository. For main it then waits for |
| 6 | 6 | # the Actions runs on Diffinery and fails if any of them fail. |
| @@ -8,9 +8,10 @@ | ||
| 8 | 8 | # Secrets go straight from GitHub's secret store to Diffinery's over HTTPS and are |
| 9 | 9 | # never printed. Needs DIFFINERY_LIVE_TOKEN (an admin token on diffinery.com). |
| 10 | 10 | # |
| 11 | -# Manual only since the cut-off: Diffinery (codician/diffinery) is this repository's home, | |
| 12 | -# GitHub's copy is frozen, and pushes here are no longer copied over. The file stays as a | |
| 13 | -# record and for one last manual run if ever needed. | |
| 11 | +# Retired since the cut-off: Diffinery (codician/diffinery) is this repository's home, | |
| 12 | +# GitHub's copy is frozen, and nothing is copied over any more. The file stays as a record | |
| 13 | +# only; its job never runs (it would copy GitHub's frozen branches over Diffinery's and write | |
| 14 | +# repository-level secrets, which now live in the production environment). | |
| 14 | 15 | on: |
| 15 | 16 | workflow_dispatch: |
| 16 | 17 | inputs: |
| @@ -40,14 +41,14 @@ | ||
| 40 | 41 | name: Copy code, secrets and variables, then check Actions on Diffinery |
| 41 | 42 | # Runs on GitHub only; the copy on Diffinery has nothing to sync to. |
| 42 | 43 | # Branch deletions aren't copied (Diffinery keeps the branch until it's deleted there). |
| 43 | - if: ${{ github.server_url == 'https://github.com' && !github.event.deleted }} | |
| 44 | + if: false | |
| 44 | 45 | runs-on: ubuntu-latest |
| 45 | 46 | timeout-minutes: 90 |
| 46 | 47 | environment: production |
| 47 | 48 | env: |
| 48 | 49 | DIFFINERY: https://diffinery.com |
| 49 | 50 | TOKEN: ${{ secrets.DIFFINERY_LIVE_TOKEN }} |
| 50 | - ORG: codician-team | |
| 51 | + ORG: codician | |
| 51 | 52 | NAME: diffinery |
| 52 | 53 | steps: |
| 53 | 54 | - name: Check the token |