11name : deploy
22
3+ # Self-deciding deploy:
4+ # * No VM tagged `computer` exists -> CREATE one from the freshly-built image
5+ # (bootstrap / disaster recovery). A fresh VM has no state, so this is the ONE
6+ # path that needs the backup secrets — passed as `exe.dev new --env` so the box
7+ # can restore from B2 on boot. (Brand-new setups still need the manual seeding
8+ # in README: tailscale authkey, pilegram env.)
9+ # * A VM tagged `computer` exists -> ACTIVATE IN PLACE over Tailscale. No VM
10+ # recreate, no node churn, no restore, and NO secrets in CI — RESTIC/B2/pilegram
11+ # already live on the box.
12+ # The next deploy after a create finds the VM and switches it onto the generation.
13+
314on :
415 workflow_dispatch :
516 inputs :
617 image_tag :
7- description : image tag to deploy (blank = the built commit's short SHA)
18+ description : image tag to create from (blank = the built commit's short SHA)
819 default : " "
920 workflow_run :
1021 workflows : [build]
1122 types : [completed]
1223 branches : [master]
1324
14- env :
15- VM_TAG : computer
16-
1725concurrency :
1826 group : deploy
1927 cancel-in-progress : false
2028
29+ env :
30+ VM_NAME : computer
31+ VM_TAG : computer
32+
2133jobs :
2234 deploy :
23- name : Redeploy
35+ name : Create-or-activate
2436 runs-on : ubuntu-24.04
2537 if : >-
2638 github.event_name == 'workflow_dispatch' ||
@@ -29,19 +41,11 @@ jobs:
2941 - uses : actions/checkout@v5
3042 with :
3143 # the commit that was built (workflow_run), else the dispatched ref.
44+ # depth 1 is enough: create uses `git rev-parse HEAD`, activate builds
45+ # from the working tree — neither needs history.
3246 ref : ${{ github.event.workflow_run.head_sha || github.sha }}
3347
34- - name : Resolve image tag and VM name
35- id : resolve
36- env :
37- IN_TAG : ${{ github.event.inputs.image_tag }}
38- run : |
39- tag="$IN_TAG"
40- [ -n "$tag" ] || tag="$(git rev-parse --short HEAD)"
41- echo "tag=$tag" >> "$GITHUB_OUTPUT"
42- echo "VM_NAME=${VM_TAG}-${tag}" >> "$GITHUB_ENV"
43-
44- - name : Configure SSH
48+ - name : Configure exe.dev SSH
4549 env :
4650 SSH_KEY : ${{ secrets.SSH_KEY }}
4751 run : |
@@ -55,36 +59,55 @@ jobs:
5559 | grep -q "SHA256:JJOP/lwiBGOMilfONPWZCXUrfK154cnJFXcqlsi6lPo" \
5660 || { echo "exe.dev host-key fingerprint mismatch"; exit 1; }
5761
58- - name : Retire older computer VMs
62+ - name : Is there a computer VM?
63+ id : check
5964 run : |
60- old=$(ssh -i ~/.ssh/id_ed25519 exe.dev ls --l --json \
61- | jq -r --arg keep "$VM_NAME" --arg tag "$VM_TAG" '
62- .vms[]
63- | select((.tags // []) | index($tag))
64- | (.vm_name // .name)
65- | select(type == "string" and . != "" and . != $keep)')
66- for vm in $old; do
67- echo "retiring $vm"
68- ssh -i ~/.ssh/id_ed25519 exe.dev rm "$vm" || true
69- done
65+ n=$(ssh -i ~/.ssh/id_ed25519 exe.dev ls --l --json \
66+ | jq -r --arg tag "$VM_TAG" '[.vms[] | select((.tags // []) | index($tag))] | length')
67+ echo "found $n VM(s) tagged $VM_TAG"
68+ if [ "$n" -gt 0 ]; then echo "exists=true"; else echo "exists=false"; fi >> "$GITHUB_OUTPUT"
7069
71- - name : Create the new VM
70+ # ---- bootstrap: no VM yet -> create one (backup secrets from CI) ----------
71+ - name : Create the computer VM
72+ if : steps.check.outputs.exists == 'false'
7273 env :
73- IMAGE_TAG : ${{ steps.resolve.outputs.tag }}
74+ IN_TAG : ${{ github.event.inputs.image_tag }}
7475 RESTIC_PASSWORD : ${{ secrets.RESTIC_PASSWORD }}
7576 B2_ACCOUNT_KEY : ${{ secrets.B2_ACCOUNT_KEY }}
7677 run : |
78+ tag="$IN_TAG"
79+ [ -n "$tag" ] || tag="$(git rev-parse --short HEAD)"
80+ echo "creating $VM_NAME from ghcr.io/ngalaiko/computer.exe:$tag"
7781 ssh -i ~/.ssh/id_ed25519 exe.dev new \
78- --image="ghcr.io/ngalaiko/computer.exe:$IMAGE_TAG " \
82+ --image="ghcr.io/ngalaiko/computer.exe:$tag " \
7983 --name "$VM_NAME" \
8084 --tag "$VM_TAG" \
8185 --env RESTIC_REPOSITORY=b2:ngalaiko-backups:exedev \
8286 --env RESTIC_PASSWORD="$RESTIC_PASSWORD" \
8387 --env B2_ACCOUNT_ID=0036cfed748e70c0000000002 \
8488 --env B2_ACCOUNT_KEY="$B2_ACCOUNT_KEY"
85-
86- # 8080 = services.ingress.publicPort, the public root port.
87- - name : Publish the public ingress port
88- run : |
89+ # 8080 = services.ingress.publicPort, the public root port.
8990 ssh -i ~/.ssh/id_ed25519 exe.dev share port "$VM_NAME" 8080
9091 ssh -i ~/.ssh/id_ed25519 exe.dev share set-public "$VM_NAME"
92+
93+ # ---- steady state: VM exists -> activate the new generation in place ------
94+ - uses : nixbuild/nix-quick-install-action@v35
95+ if : steps.check.outputs.exists == 'true'
96+
97+ # The box is reachable only over its tailnet. This tailnet OAuth client is
98+ # the only credential the in-place path needs — NOT an app secret. The client
99+ # id is public (inline); only the secret is a repo secret. The policy must let
100+ # tag:ci tailscale-ssh nikita@ the computer node (README):
101+ # "ssh": [{ "action": "accept", "src": ["tag:ci"],
102+ # "dst": ["tag:computer"], "users": ["nikita"] }]
103+ - name : Join tailnet
104+ if : steps.check.outputs.exists == 'true'
105+ uses : tailscale/github-action@v3
106+ with :
107+ oauth-client-id : kt74FE1o8p11CNTRL
108+ oauth-secret : ${{ secrets.TS_OAUTH_SECRET }}
109+ tags : tag:ci
110+
111+ - name : Activate in place
112+ if : steps.check.outputs.exists == 'true'
113+ run : nix run .#deploy
0 commit comments