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.

141 lines · 7.4 KBRaw History
1name: Sync to Diffinery
2
3# Keeps diffinery.com/codician-team/diffinery in step with this repository: every
4# branch and tag, plus the Actions secrets and variables our workflows use. On the
5# first run it creates the organization and repository. For main it then waits for
6# the Actions runs on Diffinery and fails if any of them fail.
7#
8# Secrets go straight from GitHub's secret store to Diffinery's over HTTPS and are
9# never printed. Needs DIFFINERY_LIVE_TOKEN (an admin token on diffinery.com).
10on:
11 push:
12 workflow_dispatch:
13 inputs:
14 allow_secrets:
15 description: Reason for pushing past Diffinery's push protection (admins only, audited). Leave empty normally.
16 type: string
17 default: ""
18
19permissions:
20 contents: read
21
22# One queue per ref: a push to some other branch never replaces a waiting main sync.
23# Each push event copies only its own ref, so runs for different refs can't undo
24# each other. A manual run copies every branch and tag; it queues with main, and
25# each ref it copies is leased to what Diffinery had just before, so it can't put
26# back an older commit over one another run copied meanwhile.
27concurrency:
28 group: sync-to-diffinery-${{ github.event_name == 'push' && github.ref || 'refs/heads/main' }}
29 cancel-in-progress: false
30
31jobs:
32 move:
33 name: Copy code, secrets and variables, then check Actions on Diffinery
34 # Runs on GitHub only; the copy on Diffinery has nothing to sync to.
35 # Branch deletions aren't copied (Diffinery keeps the branch until it's deleted there).
36 if: ${{ github.server_url == 'https://github.com' && !github.event.deleted }}
37 runs-on: ubuntu-latest
38 timeout-minutes: 90
39 environment: production
40 env:
41 DIFFINERY: https://diffinery.com
42 TOKEN: ${{ secrets.DIFFINERY_LIVE_TOKEN }}
43 ORG: codician-team
44 NAME: diffinery
45 steps:
46 - name: Check the token
47 run: |
48 [ -n "$TOKEN" ] || { echo "::error::Add DIFFINERY_LIVE_TOKEN (an admin token from $DIFFINERY/settings/tokens) as a repository secret first."; exit 1; }
49 curl -fsS "$DIFFINERY/api/v3/user" -H "Authorization: Bearer $TOKEN" | jq '{login, site_admin}'
50
51 - name: Create the organization and repository
52 env:
53 DESC: ${{ github.event.repository.description }}
54 run: |
55 set -euo pipefail
56 api() { curl -sS -o /tmp/out -w '%{http_code}' -X "$1" "$DIFFINERY/api/v3$2" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" ${3:+--data "$3"}; }
57 if [ "$(api GET "/users/$ORG")" = 404 ]; then
58 code=$(api POST /user/orgs "{\"login\":\"$ORG\",\"name\":\"Codician\"}"); cat /tmp/out; echo
59 [ "$code" = 201 ] || { echo "::error::could not create the $ORG organization ($code)"; exit 1; }
60 fi
61 if [ "$(api GET "/repos/$ORG/$NAME")" = 404 ]; then
62 desc=$(jq -n --arg d "$DESC" '$d')
63 code=$(api POST "/orgs/$ORG/repos" "{\"name\":\"$NAME\",\"private\":true,\"default_branch\":\"main\",\"description\":$desc}"); cat /tmp/out; echo
64 [ "$code" = 201 ] || { echo "::error::could not create $ORG/$NAME ($code)"; exit 1; }
65 fi
66 echo "$DIFFINERY/$ORG/$NAME is ready"
67
68 - name: Copy Actions secrets and variables
69 env:
70 S_CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
71 S_CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
72 S_DIFFINERY_LIVE_TOKEN: ${{ secrets.DIFFINERY_LIVE_TOKEN }}
73 S_IMPORT_GITHUB_TOKEN: ${{ secrets.IMPORT_GITHUB_TOKEN }}
74 V_ACCESS_TEAM_DOMAIN: ${{ vars.ACCESS_TEAM_DOMAIN }}
75 V_DIFFINERY_DOMAIN: ${{ vars.DIFFINERY_DOMAIN }}
76 V_DIFFINERY_ZONE: ${{ vars.DIFFINERY_ZONE }}
77 V_MAIL_DOMAIN: ${{ vars.MAIL_DOMAIN }}
78 run: |
79 set -euo pipefail
80 # The PUT is idempotent, so a transient 5xx is retried; a failure shows the answer (its request_id finds the error under Settings -> Server errors).
81 put() {
82 local out
83 out=$(curl -sS --fail-with-body --retry 3 --retry-all-errors --retry-delay 2 -X PUT "$DIFFINERY/api/v3/repos/$ORG/$NAME/actions/$1/$2" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data-binary @-) || { echo "::error::PUT actions/$1/$2 failed: $out"; return 1; }
84 }
85 for name in CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID DIFFINERY_LIVE_TOKEN IMPORT_GITHUB_TOKEN; do
86 v="S_$name"
87 if [ -n "${!v}" ]; then jq -n --arg v "${!v}" '{value: $v}' | put secrets "$name"; echo "secret $name copied"; else echo "::warning::secret $name is empty here, so it was not copied"; fi
88 done
89 for name in ACCESS_TEAM_DOMAIN DIFFINERY_DOMAIN DIFFINERY_ZONE MAIL_DOMAIN; do
90 v="V_$name"
91 if [ -n "${!v}" ]; then jq -n --arg v "${!v}" '{value: $v}' | put variables "$name"; echo "variable $name = ${!v}"; fi
92 done
93
94 - uses: actions/checkout@v4
95 with:
96 fetch-depth: 0
97 lfs: true
98
99 - name: Push the branch or tag (a manual run pushes all of them)
100 env:
101 ALLOW_SECRETS: ${{ inputs.allow_secrets }}
102 run: |
103 set -euo pipefail
104 remote="https://x-token:${TOKEN}@${DIFFINERY#https://}/$ORG/$NAME.git"
105 opts=(-q ${ALLOW_SECRETS:+-o "allow-secrets=$ALLOW_SECRETS"})
106 if [ "$GITHUB_EVENT_NAME" = push ]; then
107 git push "${opts[@]}" "$remote" "+$GITHUB_SHA:$GITHUB_REF"
108 want=$GITHUB_SHA
109 else
110 # Read Diffinery's refs first, then GitHub's: GitHub's are then at least as new.
111 declare -A there
112 while read -r sha ref; do there[$ref]=$sha; done < <(git ls-remote "$remote" 'refs/heads/*' 'refs/tags/*')
113 git fetch -q --tags --force origin '+refs/heads/*:refs/remotes/origin/*'
114 args=()
115 while read -r ref; do
116 dest="refs/heads/${ref#refs/remotes/origin/}"
117 args+=("--force-with-lease=$dest:${there[$dest]:-}" "$ref:$dest")
118 done < <(git for-each-ref --format='%(refname)' refs/remotes/origin | grep -v '/HEAD$')
119 for tag in $(git for-each-ref --format='%(refname)' refs/tags); do
120 args+=("--force-with-lease=$tag:${there[$tag]:-}" "$tag:$tag")
121 done
122 git push "${opts[@]}" "$remote" "${args[@]}"
123 want=$(git rev-parse refs/remotes/origin/main)
124 fi
125 if git lfs ls-files | grep -q .; then git lfs push --all "$remote"; fi
126 echo "branches on Diffinery:"
127 curl -fsS "$DIFFINERY/api/v3/repos/$ORG/$NAME/branches?per_page=100" -H "Authorization: Bearer $TOKEN" | jq -r '.[] | " \(.name) \(.commit.sha)"'
128 if [ "$GITHUB_REF" = refs/heads/main ]; then
129 got=$(curl -fsS "$DIFFINERY/api/v3/repos/$ORG/$NAME/branches/main" -H "Authorization: Bearer $TOKEN" | jq -r '.commit.sha')
130 [ "$want" = "$got" ] || { echo "::error::main on Diffinery is $got, expected $want"; exit 1; }
131 echo "main matches GitHub at $want"
132 fi
133 echo "want=$want" >> "$GITHUB_OUTPUT"
134 id: push
135
136 - name: Wait for Actions on Diffinery
137 # Pushes to main start CI (and, once Diffinery deploys, the deploy) there.
138 if: github.ref == 'refs/heads/main'
139 # A backstop above the script's own 75-minute deadline, in case a request hangs.
140 timeout-minutes: 80
141 run: scripts/diffinery-wait-runs.sh "$ORG/$NAME" "${{ steps.push.outputs.want }}" 75