Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Merge pull request #193 from claude/security-audit-workflows

Workflows: deploy secrets in the production environment, Deploy only from main's tip, step-scoped Cloudflare secrets, pinned pnpm action
cnbrkdmrci authored 2 parents eb6a15bb268bcecommit 9702f3ce113c04f1d16eb39e7ed1d9ce92d09f73Browse files
8 files changed+113−47
Files (8)
modified.github/workflows/ci.yml+2−2View file
@@ -24,7 +24,7 @@
2424 timeout-minutes: 20
2525 steps:
2626 - uses: actions/checkout@v4
27- - uses: pnpm/action-setup@v4
27+ - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
2828 - uses: actions/setup-node@v4
2929 with:
3030 node-version: 22
@@ -49,7 +49,7 @@
4949 shard: [1, 2]
5050 steps:
5151 - uses: actions/checkout@v4
52- - uses: pnpm/action-setup@v4
52+ - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
5353 - uses: actions/setup-node@v4
5454 with:
5555 node-version: 22
modified.github/workflows/d1-replication.yml+3−1View file
@@ -19,6 +19,8 @@
1919 check:
2020 name: Check sessions on a test database
2121 runs-on: ubuntu-latest
22+ # The Cloudflare secrets are the production environment's.
23+ environment: production
2224 timeout-minutes: 20
2325 env:
2426 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
@@ -26,7 +28,7 @@
2628 NAME: diffinery-d1-probe-${{ github.run_id }}
2729 steps:
2830 - uses: actions/checkout@v4
29- - uses: pnpm/action-setup@v4
31+ - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
3032 - uses: actions/setup-node@v4
3133 with:
3234 node-version: 22
modified.github/workflows/deploy.yml+56−5View file
@@ -10,6 +10,11 @@
1010 #
1111 # It deploys a main commit only once CI (typecheck, unit tests and the Worker bundle) has
1212 # 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.
1318 on:
1419 workflow_run:
1520 workflows: [CI]
@@ -25,13 +30,43 @@
2530 cancel-in-progress: false
2631
2732 jobs:
28- deploy:
29- name: Deploy to Cloudflare
33+ gate:
34+ name: Main's current tip
3035 # Production deploys from Diffinery itself, on its own hosted runners (any
3136 # Diffinery, so self-hosted copies deploy themselves too). Running this
3237 # workflow by hand on GitHub still deploys, as a fallback.
3338 if: ${{ github.event_name == 'workflow_dispatch' || (github.server_url != 'https://github.com' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push') }}
3439 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
3570 timeout-minutes: 45
3671 environment:
3772 name: production
@@ -40,15 +75,13 @@
4075 DIFFINERY_DOMAIN: ${{ vars.DIFFINERY_DOMAIN || 'diffinery.com' }}
4176 DIFFINERY_ZONE: ${{ vars.DIFFINERY_ZONE }}
4277 MAIL_DOMAIN: ${{ vars.MAIL_DOMAIN }}
43- CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
44- CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
4578 ACCESS_TEAM_DOMAIN: ${{ vars.ACCESS_TEAM_DOMAIN }}
4679 steps:
4780 - uses: actions/checkout@v4
4881 with:
4982 # The commit CI passed on, not whatever main is by now.
5083 ref: ${{ github.event.workflow_run.head_sha || github.sha }}
51- - uses: pnpm/action-setup@v4
84+ - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
5285 - uses: actions/setup-node@v4
5386 with:
5487 node-version: 22
@@ -57,27 +90,45 @@
5790 - name: Build runner agent and web islands
5891 run: pnpm -r --if-present build
5992 - 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 }}
6096 run: scripts/runner-image.sh
6197
6298 - name: Create or update Cloudflare resources
6399 id: bootstrap
100+ env:
101+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
102+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
64103 run: scripts/bootstrap.sh
65104
66105 - name: Deploy Worker and runner image
67106 working-directory: apps/diffinery
107+ env:
108+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
109+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
68110 run: pnpm exec wrangler deploy -c wrangler.deploy.jsonc
69111
70112 - 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 }}
71116 # The Live test follows this workflow, and its checks must run on the new image.
72117 # This job runs on an old-image container, so finishing after the rollout's last step
73118 # also shows that a deploy doesn't stop a running job.
74119 run: scripts/wait-container-rollout.sh
75120
76121 - 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 }}
77125 run: scripts/ensure-secrets.sh
78126
79127 - name: Finish Access setup on first deploy
80128 if: steps.bootstrap.outputs.access_team == ''
129+ env:
130+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
131+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
81132 run: |
82133 scripts/bootstrap.sh
83134 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 @@
1414 probe:
1515 name: Probe Docker inside a container
1616 runs-on: ubuntu-latest
17+ # The Cloudflare secrets are the production environment's.
18+ environment: production
1719 timeout-minutes: 40
1820 env:
1921 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
@@ -21,7 +23,7 @@
2123 NAME: diffinery-dind-probe-${{ github.run_id }}
2224 steps:
2325 - uses: actions/checkout@v4
24- - uses: pnpm/action-setup@v4
26+ - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
2527 - uses: actions/setup-node@v4
2628 with:
2729 node-version: 22
modified.github/workflows/e2e.yml+1−1View file
@@ -39,7 +39,7 @@
3939 shard: rest
4040 steps:
4141 - uses: actions/checkout@v4
42- - uses: pnpm/action-setup@v4
42+ - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
4343 - uses: actions/setup-node@v4
4444 with:
4545 node-version: 22
modified.github/workflows/import-campaign.yml+6−23View file
@@ -5,10 +5,8 @@
55 # came across and what ran (scripts/import-campaign.sh). Started by hand.
66 #
77 # Dispatched on GitHub, it starts the same workflow on Diffinery and prints its report;
8-# on Diffinery it does the work. Imports use the IMPORT_GITHUB_TOKEN secret when it is
9-# set (a read-only token for public repositories), otherwise GitHub's anonymous rate
10-# limit applies and large repositories take hours. Set on GitHub, it is copied to
11-# Diffinery's repository secrets before the run starts (the main sync doesn't copy it).
8+# on Diffinery it does the work. Imports run token-free (Diffinery keeps no GitHub
9+# credentials), so GitHub's anonymous rate limit applies and large repositories take hours.
1210 on:
1311 workflow_dispatch:
1412 inputs:
@@ -66,20 +64,8 @@
6664 env:
6765 DIFFINERY: https://diffinery.com
6866 TOKEN: ${{ secrets.DIFFINERY_LIVE_TOKEN }}
69- R: codician-team/diffinery
67+ R: codician/diffinery
7068 steps:
71- - name: Copy IMPORT_GITHUB_TOKEN to Diffinery
72- if: ${{ inputs.follow_run == '' && !inputs.peek && inputs.rerun_failed == '' && inputs.inspect_run == '' && inputs.push_test == '' }}
73- env:
74- IMPORT_GITHUB_TOKEN: ${{ secrets.IMPORT_GITHUB_TOKEN }}
75- run: |
76- set -euo pipefail
77- if [ -n "$IMPORT_GITHUB_TOKEN" ]; then
78- jq -n --arg v "$IMPORT_GITHUB_TOKEN" '{value: $v}' | curl -fsS --retry 4 --retry-delay 30 -o /dev/null -X PUT "$DIFFINERY/api/v3/repos/$R/actions/secrets/IMPORT_GITHUB_TOKEN" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data-binary @-
79- echo "IMPORT_GITHUB_TOKEN is set on GitHub and was copied to Diffinery"
80- else
81- echo "IMPORT_GITHUB_TOKEN is not set on GitHub; Diffinery's own copy is used if it has one"
82- fi
8369 - name: Dispatch and follow
8470 env:
8571 REPOS: ${{ inputs.repos }}
@@ -229,10 +215,11 @@
229215 if: ${{ github.server_url != 'https://github.com' }}
230216 runs-on: ubuntu-latest
231217 timeout-minutes: 360
218+ # DIFFINERY_LIVE_TOKEN is the production environment's.
219+ environment: production
232220 env:
233221 DIFFINERY: ${{ github.server_url }}
234222 TOKEN: ${{ secrets.DIFFINERY_LIVE_TOKEN }}
235- GITHUB_TOKEN_FOR_IMPORT: ${{ secrets.IMPORT_GITHUB_TOKEN }}
236223 steps:
237224 - uses: actions/checkout@v4
238225 - name: Import and run
@@ -241,11 +228,7 @@
241228 MINUTES: ${{ inputs.minutes }}
242229 RUN_WORKFLOWS: ${{ inputs.run_workflows }}
243230 run: |
244- if [ -n "$GITHUB_TOKEN_FOR_IMPORT" ]; then
245- echo "::notice::Mode: imports use the IMPORT_GITHUB_TOKEN secret"
246- else
247- echo "::warning::Mode: token-free. IMPORT_GITHUB_TOKEN is not set, so imports use GitHub's anonymous rate limit"
248- fi
231+ echo "::notice::Mode: token-free, so imports use GitHub's anonymous rate limit"
249232 # shellcheck disable=SC2086
250233 REPORT=report.md scripts/import-campaign.sh $REPOS
251234 cat report.md >> "$GITHUB_STEP_SUMMARY"
modified.github/workflows/live-test.yml+35−8View file
@@ -6,8 +6,10 @@
66 # queue). Each run works in a throwaway private repository that it
77 # deletes at the end.
88 #
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.
1113 on:
1214 workflow_dispatch:
1315 workflow_run:
@@ -17,6 +19,8 @@
1719
1820 permissions:
1921 contents: read
22+ # To see whether the Deploy run it follows deployed anything.
23+ actions: read
2024
2125 concurrency:
2226 group: live-test
@@ -29,6 +33,7 @@
2933 if: ${{ github.event_name == 'workflow_dispatch' || (github.server_url == 'https://diffinery.com' && github.event.workflow_run.conclusion == 'success') }}
3034 runs-on: ubuntu-latest
3135 timeout-minutes: 45
36+ environment: production
3237 env:
3338 DIFFINERY: https://diffinery.com
3439 TOKEN: ${{ secrets.DIFFINERY_LIVE_TOKEN }}
@@ -37,9 +42,27 @@
3742 steps:
3843 - name: Check the token
3944 id: token
45+ env:
46+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
47+ DEPLOY_RUN: ${{ github.event.workflow_run.id }}
4048 run: |
49+ if [ "$GITHUB_EVENT_NAME" = workflow_run ]; then
50+ # Deploy's gate skips a commit that is no longer main's tip (the newer commit's run deploys it):
51+ # then nothing new is live, and this run would only test the site as it was.
52+ deployed=$(curl -fsS "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs/$DEPLOY_RUN/jobs" -H "Authorization: Bearer $GH_TOKEN" | jq -r '[.jobs[] | select(.name == "Deploy to Cloudflare") | .conclusion][0] // "unknown"' || echo unknown)
53+ if [ "$deployed" = skipped ]; then
54+ echo "::notice::Deploy deployed nothing for $HEAD_SHA (main has moved on), so there is nothing new to test"
55+ echo "skip=true" >> "$GITHUB_OUTPUT"
56+ exit 0
57+ fi
58+ fi
4159 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."
60+ # After a deploy of diffinery.com a missing token is an error: a green run would mean nothing was tested.
61+ if [ "$GITHUB_EVENT_NAME" = workflow_run ]; then
62+ 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."
63+ exit 1
64+ fi
65+ 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."
4366 echo "skip=true" >> "$GITHUB_OUTPUT"
4467 exit 0
4568 fi
@@ -145,7 +168,11 @@
145168 if: steps.token.outputs.skip != 'true'
146169 run: |
147170 set -euo pipefail
148- s=$(curl -fsS "$DIFFINERY/api/v3/diffinery/admin/email" -H "Authorization: Bearer $TOKEN")
171+ s=$(curl -sS -w '\n%{http_code}' "$DIFFINERY/api/v3/diffinery/admin/email" -H "Authorization: Bearer $TOKEN")
172+ code=${s##*$'\n'}; s=${s%$'\n'*}
173+ # Only a site administrator's token reads the email settings, and the live-test account isn't one.
174+ if [ "$code" = 403 ]; then echo "::notice::skipped: the live-test token isn't a site administrator's"; exit 0; fi
175+ [ "$code" = 200 ] || { echo "::error::/diffinery/admin/email answered $code"; exit 1; }
149176 echo "$s" | jq '{sending, queue, from, last_24h}'
150177 [ "$(jq -r .sending <<<"$s")" = true ] || { echo "::error::the Worker has no Email Sending binding"; exit 1; }
151178 [ "$(jq -r .queue <<<"$s")" = true ] || { echo "::error::the Worker has no mail queue binding"; exit 1; }
@@ -678,15 +705,14 @@
678705 env:
679706 # A small public repository with issues and pull requests; a repository variable can change it.
680707 SOURCE: ${{ vars.LIVE_IMPORT_SOURCE || 'octocat/git-consortium' }}
681- # A read-only GitHub token (copied here by the sync), so GitHub's rate limit doesn't skip the check. Never printed.
682- GITHUB_TOKEN_FOR_IMPORT: ${{ secrets.IMPORT_GITHUB_TOKEN }}
708+ # Token-free: Diffinery keeps no GitHub credentials of its own (the importer takes a person's token when they import).
709+ GITHUB_TOKEN_FOR_IMPORT: ""
683710 run: |
684711 set -euo pipefail
685712 api() { curl -fsS -X "$1" "$DIFFINERY/api/v3$2" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" ${3:+--data "$3"}; }
686713 name="$REPO-import"
687714 body=$(jq -nc --arg s "$SOURCE" --arg o "$OWNER" --arg n "$name" --arg t "${GITHUB_TOKEN_FOR_IMPORT:-}" \
688715 '{source: $s, owner: $o, name: $n, visibility: "private"} + (if $t == "" then {} else {github_token: $t} end)')
689- if [ -n "${GITHUB_TOKEN_FOR_IMPORT:-}" ]; then echo "importing with IMPORT_GITHUB_TOKEN"; else echo "::warning::IMPORT_GITHUB_TOKEN is not set, so the import runs token-free"; fi
690716 # Token-free, GitHub allows 60 requests an hour per address: a rate limit skips this check with a warning.
691717 # Starting the import reads the repository from GitHub; a GitHub outage (502 "could not reach GitHub")
692718 # is retried, and skips this check with a warning if it outlasts the retries. While other imports hold
@@ -770,7 +796,8 @@
770796 sleep 10
771797 done
772798 if [ -z "$run" ]; then
773- # Whether the scheduler stopped ticking (last_tick_at is old) or ticked without starting the run.
799+ # Whether the scheduler stopped ticking (last_tick_at is old) or ticked without starting the run
800+ # (site administrators only: another token prints the 403).
774801 echo "scheduler: $(curl -sS "$DIFFINERY/api/v3/diffinery/admin/scheduler" -H "Authorization: Bearer $TOKEN") (now $(date -u +%FT%TZ))"
775802 echo "::error::no scheduled run started"
776803 exit 1
modified.github/workflows/sync-to-diffinery.yml+7−6View file
@@ -1,6 +1,6 @@
11 name: Sync to Diffinery
22
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
44 # branch and tag, plus the Actions secrets and variables our workflows use. On the
55 # first run it creates the organization and repository. For main it then waits for
66 # the Actions runs on Diffinery and fails if any of them fail.
@@ -8,9 +8,10 @@
88 # Secrets go straight from GitHub's secret store to Diffinery's over HTTPS and are
99 # never printed. Needs DIFFINERY_LIVE_TOKEN (an admin token on diffinery.com).
1010 #
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).
1415 on:
1516 workflow_dispatch:
1617 inputs:
@@ -40,14 +41,14 @@
4041 name: Copy code, secrets and variables, then check Actions on Diffinery
4142 # Runs on GitHub only; the copy on Diffinery has nothing to sync to.
4243 # 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
4445 runs-on: ubuntu-latest
4546 timeout-minutes: 90
4647 environment: production
4748 env:
4849 DIFFINERY: https://diffinery.com
4950 TOKEN: ${{ secrets.DIFFINERY_LIVE_TOKEN }}
50- ORG: codician-team
51+ ORG: codician
5152 NAME: diffinery
5253 steps:
5354 - name: Check the token