This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / docs / DOCS.md
86 kB 2894 lines
1--- 2title: Tangled docs 3author: The Tangled Contributors 4date: 21 Sun, Dec 2025 5abstract: | 6 Tangled is a decentralized code hosting and collaboration 7 platform. Every component of Tangled is open-source and 8 self-hostable. [tangled.org](https://tangled.org) also 9 provides hosting and CI services that are free to use. 10 11 There are several models for decentralized code 12 collaboration platforms, ranging from ActivityPub’s 13 (Forgejo) federated model, to Radicle’s entirely P2P model. 14 Our approach attempts to be the best of both worlds by 15 adopting the AT Protocol—a protocol for building decentralized 16 social applications with a central identity 17 18 Our approach to this is the idea of “knots”. Knots are 19 lightweight, headless servers that enable users to host Git 20 repositories with ease. Knots are designed for either single 21 or multi-tenant use which is perfect for self-hosting on a 22 Raspberry Pi at home, or larger “community” servers. By 23 default, Tangled provides managed knots where you can host 24 your repositories for free. 25 26 The appview at tangled.org acts as a consolidated "view" 27 into the whole network, allowing users to access, clone and 28 contribute to repositories hosted across different knots 29 seamlessly. 30--- 31 32# Quick start guide 33 34## Login or sign up 35 36You can [login](https://tangled.org) by using your AT Protocol 37account. If you are unclear on what that means, simply head 38to the [signup](https://tangled.org/signup) page and create 39an account. By doing so, you will be choosing Tangled as 40your account provider (you will be granted a handle of the 41form `user.tngl.sh`). 42 43In the AT Protocol network, users are free to choose their account 44provider (known as a "Personal Data Service", or PDS), and 45login to applications that support AT accounts. 46 47You can think of it as "one account for all of the atmosphere"! 48 49If you already have an AT account (you may have one if you 50signed up to Bluesky, for example), you can login with the 51same handle on Tangled (so just use `user.bsky.social` on 52the login page). 53 54## Add an SSH key 55 56Once you are logged in, you can start creating repositories 57and pushing code. Tangled supports pushing git repositories 58over SSH. 59 60First, you'll need to generate an SSH key if you don't 61already have one: 62 63```bash 64ssh-keygen -t ed25519 -C "foo@bar.com" 65``` 66 67When prompted, save the key to the default location 68(`~/.ssh/id_ed25519`) and optionally set a passphrase. 69 70Copy your public key to your clipboard: 71 72```bash 73# on X11 74cat ~/.ssh/id_ed25519.pub | xclip -sel c 75 76# on wayland 77cat ~/.ssh/id_ed25519.pub | wl-copy 78 79# on macos 80cat ~/.ssh/id_ed25519.pub | pbcopy 81``` 82 83Now, navigate to 'Settings' -> 'Keys' and hit 'Add Key', 84paste your public key, give it a descriptive name, and hit 85save. 86 87## Create a repository 88 89Once your SSH key is added, create your first repository: 90 911. Hit the green `+` icon on the topbar, and select 92 repository 932. Enter a repository name 943. Add a description 954. Choose a knotserver to host this repository on 965. Hit create 97 98Knots are self-hostable, lightweight Git servers that can 99host your repository. Unlike traditional code forges, your 100code can live on any server. Read the [Knots](TODO) section 101for more. 102 103## Configure SSH 104 105To ensure Git uses the correct SSH key and connects smoothly 106to Tangled, add this configuration to your `~/.ssh/config` 107file: 108 109``` 110Host tangled.org 111 Hostname tangled.org 112 User git 113 IdentityFile ~/.ssh/id_ed25519 114 AddressFamily inet 115``` 116 117This tells SSH to use your specific key when connecting to 118Tangled and prevents authentication issues if you have 119multiple SSH keys. 120 121Note that this configuration only works for knotservers that 122are hosted by tangled.org. If you use a custom knot, refer 123to the [Knots](TODO) section. 124 125## Push your first repository 126 127Initialize a new Git repository: 128 129```bash 130mkdir my-project 131cd my-project 132 133git init 134echo "# My Project" > README.md 135``` 136 137Add some content and push! 138 139```bash 140git add README.md 141git commit -m "Initial commit" 142git remote add origin git@tangled.org:user.tngl.sh/my-project 143git push -u origin main 144``` 145 146That's it! Your code is now hosted on Tangled. 147 148## Migrating an existing repository 149 150Moving your repositories from GitHub, GitLab, Bitbucket, or 151any other Git forge to Tangled is straightforward. You'll 152simply change your repository's remote URL. At the moment, 153Tangled does not have any tooling to migrate data such as 154GitHub issues or pull requests. 155 156First, create a new repository on tangled.org as described 157in the [Quick Start Guide](#create-a-repository). 158 159Navigate to your existing local repository: 160 161```bash 162cd /path/to/your/existing/repo 163``` 164 165You can inspect your existing Git remote like so: 166 167```bash 168git remote -v 169``` 170 171You'll see something like: 172 173```bash 174origin git@github.com:username/my-project.git (fetch) 175origin git@github.com:username/my-project.git (push) 176``` 177 178Update the remote URL to point to tangled: 179 180```bash 181git remote set-url origin git@tangled.org:user.tngl.sh/my-project 182``` 183 184Verify the change: 185 186```bash 187git remote -v 188``` 189 190You should now see: 191 192```bash 193origin git@tangled.org:user.tngl.sh/my-project (fetch) 194origin git@tangled.org:user.tngl.sh/my-project (push) 195``` 196 197Push all your branches and tags to Tangled: 198 199```bash 200git push -u origin --all 201git push -u origin --tags 202``` 203 204Your repository is now migrated to Tangled! All commit 205history, branches, and tags have been preserved. 206 207## Mirroring a repository to Tangled 208 209If you want to maintain your repository on multiple forges 210simultaneously, for example, keeping your primary repository 211on GitHub while mirroring to Tangled for backup or 212redundancy, you can do so by adding [multiple remotes](https://git-scm.com/docs/git-push#_remotes). 213 214You can configure your local repository to push to both 215Tangled and, say, GitHub. You may already have the following 216setup: 217 218```bash 219$ git remote -v 220origin git@github.com:username/my-project.git (fetch) 221origin git@github.com:username/my-project.git (push) 222``` 223 224Now add Tangled as an additional push URL to the same 225remote: 226 227```bash 228git remote set-url --add --push origin git@tangled.org:user.tngl.sh/my-project 229``` 230 231You also need to re-add the original URL as a push 232destination (Git will now use the original URL to fetch only): 233 234```bash 235git remote set-url --add --push origin git@github.com:username/my-project.git 236``` 237 238Verify your configuration: 239 240```bash 241$ git remote -v 242origin git@github.com:username/my-project.git (fetch) 243origin git@tangled.org:user.tngl.sh/my-project (push) 244origin git@github.com:username/my-project.git (push) 245``` 246 247Notice that there's one fetch URL (the primary remote) and 248two push URLs. Now, whenever you push, Git will 249automatically push to both remotes: 250 251```bash 252git push origin main 253``` 254 255This single command pushes your `main` branch to both GitHub 256and Tangled simultaneously. 257 258To push all branches and tags: 259 260```bash 261git push origin --all 262git push origin --tags 263``` 264 265If you prefer more control over which remote you push to, 266you can maintain separate remotes: 267 268```bash 269git remote add github git@github.com:username/my-project.git 270git remote add tangled git@tangled.org:user.tngl.sh/my-project 271``` 272 273Then push to each explicitly: 274 275```bash 276git push github main 277git push tangled main 278``` 279 280# Hosting websites on Tangled 281 282You can serve static websites directly from your git repositories on 283Tangled. If you've used GitHub Pages or Codeberg Pages, this should feel 284familiar. 285 286## Overview 287 288Every user gets a sites domain. If you signed up through Tangled's own 289PDS (`tngl.sh`), your sites domain is automatically 290`<your-handle>.tngl.sh` no setup needed. Otherwise, you can claim a 291`<subdomain>.tngl.io` domain from your settings. 292 293You can serve multiple sites per domain: 294 295- One **index site** served at the root of your domain (e.g. 296 `alice.tngl.sh`) 297- Any number of **sub-path sites** served under the repository name 298 (e.g. `alice.tngl.sh/my-project`) 299 300## Claiming a domain 301 302If you don't have a `tngl.sh` handle, you need to claim a domain before 303publishing sites: 304 3051. Go to **Settings → Sites** 3062. Enter a subdomain (e.g. `alice` to claim `alice.tngl.io`) 3073. Click **claim** 308 309You can only hold one domain at a time. Releasing a domain puts it in a 31030-day cooldown before anyone else can claim it. 311 312## Configuring a site for a repository 313 3141. Navigate to your repository 3152. Go to **Settings → Sites** 3163. Choose a **branch** to deploy from 3174. Set the **deploy directory** — the path within the repository 318 containing your `index.html`. Use `/` for the root, or a subdirectory 319 like `/docs` or `/public` 3205. Choose the **site type**: 321 - **Index site** — served at the root of your domain (e.g. 322 `alice.tngl.sh`) 323 - **Sub-path site** — served under the repository name (e.g. 324 `alice.tngl.sh/my-project`) 3256. Click **save** 326 327The site will be deployed automatically. You can see the status of your 328previous deploys in the **Recent Deploys** section at the bottom of the 329page. 330 331Sites are redeployed automatically on every push to the configured 332branch. 333 334## Custom domains 335 336Tangled currently doesn't support custom domains for sites. This will be 337added in a future update. 338 339## Deploy directory 340 341The deploy directory is the path within your repository that Tangled 342serves as the site root. It must contain an `index.html`. 343 344| Deploy directory | Result | 345|---|---| 346| `/` | Serves the repository root | 347| `/docs` | Serves the `docs/` subdirectory | 348| `/public` | Serves the `public/` subdirectory | 349 350Directories are served with automatic `index.html` resolution -- a 351request to `/about` will serve `/about/index.html` if it exists. 352 353## Site types 354 355| Type | URL | 356|---|---| 357| Index site | `alice.tngl.sh` | 358| Sub-path site | `alice.tngl.sh/my-project` | 359 360Only one repository can be the index site for a given domain at a time. 361If another repository already holds the index site, you will see a 362notice in the settings and only the sub-path option will be available. 363 364## Deploy triggers 365 366A deployment is triggered automatically when: 367 368- You push to the configured branch 369- You change the site configuration (branch, deploy directory, or site 370 type) 371 372## Disabling a site 373 374To stop serving a site, go to **Settings → Sites** in your repository 375and click **Disable**. This removes the site configuration and stops 376serving the site. The deployed files are also deleted from storage. 377 378Releasing your domain from **Settings → Sites** at the account level 379will disable all sites associated with it and delete their files. 380 381 382# Knot self-hosting guide 383 384So you want to run your own knot server? Great! Here are a few prerequisites: 385 3861. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind. 3872. A (sub)domain name. People generally use `knot.example.com`. 3883. A valid SSL certificate for your domain. 389 390## NixOS 391 392Refer to the [knot 393module](https://tangled.org/tangled.org/core/blob/master/nix/modules/knot.nix) 394for a full list of options. Sample configurations: 395 396- [The test VM](https://tangled.org/tangled.org/core/blob/master/nix/vm.nix#L85) 397- [@pyrox.dev/nix](https://tangled.org/pyrox.dev/nix/blob/c2b644c214d278af12523618de952ee2eab1af3d/hosts/marvin/services/tangled.nix#L15-26) 398 399## Docker 400 401Refer to 402[@tangled.org/knot-docker](https://tangled.org/@tangled.org/knot-docker). 403Note that this is community maintained. 404 405## Manual setup 406 407First, clone this repository: 408 409``` 410git clone https://tangled.org/@tangled.org/core 411``` 412 413Then, build the `knot` CLI. This is the knot administration 414and operation tool. For the purpose of this guide, we're 415only concerned with these subcommands: 416 417- `knot server`: the main knot server process, typically 418 run as a supervised service 419- `knot guard`: handles role-based access control for git 420 over SSH (you'll never have to run this yourself) 421- `knot keys`: fetches SSH keys associated with your knot; 422 we'll use this to generate the SSH 423 `AuthorizedKeysCommand` 424 425``` 426cd core 427export CGO_ENABLED=1 428go build -o knot ./cmd/knot 429``` 430 431Next, move the `knot` binary to a location owned by `root` -- 432`/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`: 433 434``` 435sudo mv knot /usr/local/bin/knot 436sudo chown root:root /usr/local/bin/knot 437``` 438 439This is necessary because SSH `AuthorizedKeysCommand` requires [really 440specific permissions](https://stackoverflow.com/a/27638306). The 441`AuthorizedKeysCommand` specifies a command that is run by `sshd` to 442retrieve a user's public SSH keys dynamically for authentication. Let's 443set that up. 444 445``` 446sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 447Match User git 448 AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys 449 AuthorizedKeysCommandUser nobody 450EOF 451``` 452 453Then, reload `sshd`: 454 455``` 456sudo systemctl reload ssh 457``` 458 459Next, create the `git` user. We'll use the `git` user's home directory 460to store repositories: 461 462``` 463sudo adduser git 464``` 465 466Create `/home/git/.knot.env` with the following, updating the values as 467necessary. The `KNOT_SERVER_OWNER` should be set to your 468DID, you can find your DID in the [Settings](https://tangled.org/settings) page. 469 470``` 471KNOT_REPO_SCAN_PATH=/home/git 472KNOT_SERVER_HOSTNAME=knot.example.com 473APPVIEW_ENDPOINT=https://tangled.org 474KNOT_SERVER_OWNER=did:plc:foobar 475KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 476KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 477``` 478 479If you run a Linux distribution that uses systemd, you can 480use the provided service file to run the server. Copy 481[`knotserver.service`](https://tangled.org/tangled.org/core/blob/master/systemd/knotserver.service) 482to `/etc/systemd/system/`. Then, run: 483 484``` 485systemctl enable knotserver 486systemctl start knotserver 487``` 488 489The last step is to configure a reverse proxy like Nginx or Caddy to front your 490knot. Here's an example configuration for Nginx: 491 492``` 493server { 494 listen 80; 495 listen [::]:80; 496 server_name knot.example.com; 497 498 location / { 499 proxy_pass http://localhost:5555; 500 proxy_set_header Host $host; 501 proxy_set_header X-Real-IP $remote_addr; 502 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 503 proxy_set_header X-Forwarded-Proto $scheme; 504 } 505 506 # wss endpoint for git events 507 location /events { 508 proxy_set_header X-Forwarded-For $remote_addr; 509 proxy_set_header Host $http_host; 510 proxy_set_header Upgrade websocket; 511 proxy_set_header Connection Upgrade; 512 proxy_pass http://localhost:5555; 513 } 514 # additional config for SSL/TLS go here. 515} 516 517``` 518 519Remember to use Let's Encrypt or similar to procure a certificate for your 520knot domain. 521 522You should now have a running knot server! You can finalize 523your registration by hitting the `verify` button on the 524[/settings/knots](https://tangled.org/settings/knots) page. This simply creates 525a record on your PDS to announce the existence of the knot. 526 527### Custom paths 528 529(This section applies to manual setup only. Docker users should edit the mounts 530in `docker-compose.yml` instead.) 531 532Right now, the database and repositories of your knot lives in `/home/git`. You 533can move these paths if you'd like to store them in another folder. Be careful 534when adjusting these paths: 535 536- Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent 537 any possible side effects. Remember to restart it once you're done. 538- Make backups before moving in case something goes wrong. 539- Make sure the `git` user can read and write from the new paths. 540 541#### Database 542 543As an example, let's say the current database is at `/home/git/knotserver.db`, 544and we want to move it to `/home/git/database/knotserver.db`. 545 546Copy the current database to the new location. Make sure to copy the `.db-shm` 547and `.db-wal` files if they exist. 548 549``` 550mkdir /home/git/database 551cp /home/git/knotserver.db* /home/git/database 552``` 553 554In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to 555the new file path (_not_ the directory): 556 557``` 558KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db 559``` 560 561#### Repositories 562 563As an example, let's say the repositories are currently in `/home/git`, and we 564want to move them into `/home/git/repositories`. 565 566Create the new folder, then move the existing repositories (if there are any): 567 568``` 569mkdir /home/git/repositories 570# move all DIDs into the new folder; these will vary for you! 571mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories 572``` 573 574In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH` 575to the new directory: 576 577``` 578KNOT_REPO_SCAN_PATH=/home/git/repositories 579``` 580 581Similarly, update your `sshd` `AuthorizedKeysCommand` to use the updated 582repository path: 583 584``` 585sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 586Match User git 587 AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys -git-dir /home/git/repositories 588 AuthorizedKeysCommandUser nobody 589EOF 590``` 591 592Make sure to restart your SSH server! 593 594#### MOTD (message of the day) 595 596To configure the MOTD used ("Welcome to this knot!" by default), edit the 597`/home/git/motd` file: 598 599``` 600printf "Hi from this knot!\n" > /home/git/motd 601``` 602 603Note that you should add a newline at the end if setting a non-empty message 604since the knot won't do this for you. 605 606## Secure Mode 607 608Secure Mode isolates each `git` subprocess to the repository it is 609operating on, using two mechanisms: 610 611- **Linux Landlock** restricts the filesystem paths the subprocess 612 can access -- it can only read/write its own repository and the 613 system directories it needs to run. 614- **UID isolation** runs each subprocess as a virtual UID assigned 615 to the repository owner, so that repositories belonging to 616 different owners are isolated from each other at the OS level 617 even if Landlock were somehow bypassed. 618 619Secure Mode requires: 620 621- Linux kernel >= 5.19 (Landlock V2). This is the minimum needed 622 for `git push` to work, because receive-pack's quarantine 623 migration uses cross-directory rename which requires the 624 Landlock `REFER` access right (added in V2). Kernels 5.13-5.18 625 support Landlock V1 and clones will work, but pushes will fail 626 with cross-device link errors. On kernels without any Landlock 627 support (< 5.13), the sandbox call is a no-op: UID isolation 628 still applies but no filesystem restriction is enforced. 629- `CAP_SETUID`, `CAP_SETGID`, and `CAP_CHOWN` available to the 630 knot process. The NixOS module grants these automatically; for 631 manual setups see the `setcap` step below. 632 633### NixOS 634 635Add `server.secureMode = true;` to your knot module configuration: 636 637```nix 638services.tangled.knot = { 639 server.secureMode = true; 640 # ... other options 641}; 642``` 643 644The NixOS module handles everything else automatically: 645 646- Grants the required capabilities to the knot service via 647 `AmbientCapabilities` in the systemd unit. 648- Installs a capability-bearing wrapper at 649 `/run/wrappers/bin/knot` via `security.wrappers`, so that 650 SSH-invoked git operations (pushes) also run under the correct 651 UID without requiring the service to run as root. 652- Runs `knot migrate-isolation` at service start to chown 653 existing repositories to their virtual UIDs. 654 655### Manual setup 656 657**Step 1.** Grant the required capabilities to the knot binary. 658This allows the knot process to switch to virtual UIDs at runtime 659without running as root. You will need to repeat this step 660whenever the binary is updated. 661 662``` 663sudo setcap cap_setuid,cap_setgid,cap_chown+eip /usr/local/bin/knot 664``` 665 666**Step 2.** Run the migration tool to assign virtual UIDs to all 667existing repositories and set their filesystem permissions. This 668must be run as root: 669 670``` 671sudo knot migrate-isolation \ 672 --git-dir /home/git \ 673 --db /home/git/knotserver.db \ 674 --internal-api 127.0.0.1:5444 675``` 676 677You can re-run this at any time with `--force` to reapply 678permissions (e.g. after a manual repair or after updating the 679binary). 680 681**Step 2a.** Ensure the home directory is traversable by 682non-group users. Git subprocesses run as virtual UIDs that are 683not in the git group, and they need to resolve 684`$HOME/.config/git/config` to load the global config: 685 686``` 687sudo chmod o+x /home/git 688``` 689 690This adds only the execute bit, not read -- the virtual UIDs can 691traverse to known paths but cannot list directory contents. 692 693**Step 3.** Enable Secure Mode in your environment file: 694 695``` 696KNOT_SERVER_SECURE_MODE=true 697``` 698 699Or pass it as a flag: 700 701``` 702knot server --secure-mode 703``` 704 705**Step 4.** Regenerate the `AuthorizedKeysCommand` with the 706`-secure-mode` flag. This causes `knot keys` to emit guard 707command lines that include `-secure-mode`, so SSH pushes also 708get UID isolation: 709 710``` 711sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 712Match User git 713 AuthorizedKeysCommand /usr/local/bin/knot keys \ 714 -o authorized-keys -secure-mode 715 AuthorizedKeysCommandUser nobody 716EOF 717``` 718 719Reload `sshd` after making this change. 720 721> **Note:** the server will refuse to start in Secure Mode if any 722> repositories have not yet been isolation-migrated. Re-run 723> `migrate-isolation` if you see this error. 724 725## Troubleshooting 726 727If you run your own knot, you may run into some of these 728common issues. You can always join the 729[IRC](https://web.libera.chat/#tangled) or 730[Discord](https://chat.tangled.org/) if this section does 731not help. 732 733### Unable to push 734 735If you are unable to push to your knot or repository: 736 7371. First, ensure that you have added your SSH public key to 738 your account 7392. Check to see that your knot has synced the key by running 740 `knot keys` 7413. Check to see if git is supplying the correct private key 742 when pushing: `GIT_SSH_COMMAND="ssh -v" git push ...` 7434. Check to see if `sshd` on the knot is rejecting the push 744 for some reason: `journalctl -xeu ssh` (or `sshd`, 745 depending on your machine). These logs are unavailable if 746 using docker. 7475. Check to see if the knot itself is rejecting the push, 748 depending on your setup, the logs might be in one of the 749 following paths: 750 - `/tmp/knotguard.log` 751 - `/home/git/log` 752 - `/home/git/guard.log` 753 754# Spindles 755 756## Pipelines 757 758Spindle workflows allow you to write CI/CD pipelines in a 759simple format. They're located in the `.tangled/workflows` 760directory at the root of your repository, and are defined 761using YAML. 762 763A workflow has a set of common fields that apply no matter 764which engine you pick: 765 766- [Trigger](#trigger): A **required** field that defines 767 when a workflow should be triggered. 768- [Engine](#engine): A **required** field that defines which 769 engine a workflow should run on. 770- [Clone options](#clone-options): An **optional** field 771 that defines how the repository should be cloned. 772- [Environment](#environment): An **optional** field that 773 allows you to define environment variables. 774- [Steps](#steps): An **optional** field that allows you to 775 define what steps should run in the workflow. 776 777On top of these, each engine has its own options for things 778like dependencies and images. See [Engines](#engines) for 779the per-engine fields. 780 781### Trigger 782 783The first thing to add to a workflow is the trigger, which 784defines when a workflow runs. This is defined using a `when` 785field, which takes in a list of conditions. Each condition 786has the following fields: 787 788- `event`: This is a **required** field that defines when 789 your workflow should run. It's a list that can take one or 790 more of the following values: 791 - `push`: The workflow should run every time a commit is 792 pushed to the repository. 793 - `pull_request`: The workflow should run every time a 794 pull request is made or updated. 795 - `manual`: The workflow can be triggered manually. 796- `branch`: Defines which branches the workflow should run 797 for. If used with the `push` event, commits to the 798 branch(es) listed here will trigger the workflow. If used 799 with the `pull_request` event, updates to pull requests 800 targeting the branch(es) listed here will trigger the 801 workflow. This field has no effect with the `manual` 802 event. Supports glob patterns using `*` and `**` (e.g., 803 `main`, `develop`, `release-*`). Either `branch` or `tag` 804 (or both) must be specified for `push` events. 805- `tag`: Defines which tags the workflow should run for. 806 Only used with the `push` event - when tags matching the 807 pattern(s) listed here are pushed, the workflow will 808 trigger. This field has no effect with `pull_request` or 809 `manual` events. Supports glob patterns using `*` and `**` 810 (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or 811 `tag` (or both) must be specified for `push` events. 812 813For example, if you'd like to define a workflow that runs 814when commits are pushed to the `main` and `develop` 815branches, or when pull requests that target the `main` 816branch are updated, or manually, you can do so with: 817 818```yaml 819when: 820 - event: ["push", "manual"] 821 branch: ["main", "develop"] 822 - event: ["pull_request"] 823 branch: ["main"] 824``` 825 826You can also trigger workflows on tag pushes. For instance, 827to run a deployment workflow when tags matching `v*` are 828pushed: 829 830```yaml 831when: 832 - event: ["push"] 833 tag: ["v*"] 834``` 835 836You can even combine branch and tag patterns in a single 837constraint (the workflow triggers if either matches): 838 839```yaml 840when: 841 - event: ["push"] 842 branch: ["main", "release-*"] 843 tag: ["v*", "stable"] 844``` 845 846To skip CI for a push, pass a Git push option: 847 848```sh 849git push -o skip-ci 850``` 851 852`ci-skip` is also accepted. 853 854### Engine 855 856Next is the engine on which the workflow should run, defined 857using the **required** `engine` field. The currently 858supported engines are: 859 860- `nixery`: This uses an instance of 861 [Nixery](https://nixery.dev) to run steps, which allows 862 you to add [dependencies](#dependencies) from 863 Nixpkgs (https://github.com/NixOS/nixpkgs). You can 864 search for packages on https://search.nixos.org, and 865 there's a pretty good chance the package(s) you're looking 866 for will be there. 867 See [Nixery engine](#nixery-engine). 868- `microvm`: Runs the whole workflow inside its own 869 microVM. Has configuration features for NixOS images 870 that will let you enable services, do Docker-in-VM, etc. 871 See [microVM engine](#microvm-engine). 872 873Example: 874 875```yaml 876engine: "nixery" 877``` 878 879Each engine also adds its own workflow fields (dependencies, 880images, services, and so on). These are documented under 881[Engines](#engines). 882 883### Clone options 884 885When a workflow starts, the first step is to clone the 886repository. You can customize this behavior using the 887**optional** `clone` field. It has the following fields: 888 889- `skip`: Setting this to `true` will skip cloning the 890 repository. This can be useful if your workflow is doing 891 something that doesn't require anything from the 892 repository itself. This is `false` by default. 893- `depth`: This sets the number of commits, or the "clone 894 depth", to fetch from the repository. For example, if you 895 set this to 2, the last 2 commits will be fetched. By 896 default, the depth is set to 1, meaning only the most 897 recent commit will be fetched, which is the commit that 898 triggered the workflow. 899- `submodules`: If you use Git submodules 900 (https://git-scm.com/book/en/v2/Git-Tools-Submodules) 901 in your repository, setting this field to `true` will 902 recursively fetch all submodules. This is `false` by 903 default. 904 905The default settings are: 906 907```yaml 908clone: 909 skip: false 910 depth: 1 911 submodules: false 912``` 913 914### Environment 915 916The `environment` field allows you define environment 917variables that will be available throughout the entire 918workflow. **Do not put secrets here, these environment 919variables are visible to anyone viewing the repository. You 920can add secrets for pipelines in your repository's 921settings.** 922 923Example: 924 925```yaml 926environment: 927 GOOS: "linux" 928 GOARCH: "arm64" 929 NODE_ENV: "production" 930 MY_ENV_VAR: "MY_ENV_VALUE" 931``` 932 933By default, the following environment variables are set: 934 935- `CI` - Always set to `true` to indicate a CI environment 936- `TANGLED_PIPELINE_ID` - The AT URI of the current pipeline 937- `TANGLED_PIPELINE_KIND` - One of `push`, `pull_request` or 938 `manual` 939- `TANGLED_REPO_KNOT` - The repository's knot hostname 940- `TANGLED_REPO_DID` - The DID of the repository owner 941- `TANGLED_REPO_NAME` - The name of the repository 942- `TANGLED_REPO_DEFAULT_BRANCH` - The default branch of the 943 repository 944- `TANGLED_REPO_URL` - The full URL to the repository 945 946These variables are only available when the pipeline is 947triggered by a push: 948 949- `TANGLED_REF` - The full git reference (e.g., 950 `refs/heads/main` or `refs/tags/v1.0.0`) 951- `TANGLED_REF_NAME` - The short name of the reference 952 (e.g., `main` or `v1.0.0`) 953- `TANGLED_REF_TYPE` - The type of reference, either 954 `branch` or `tag` 955- `TANGLED_SHA` - The commit SHA that triggered the pipeline 956- `TANGLED_COMMIT_SHA` - Alias for `TANGLED_SHA` 957 958These variables are only available when the pipeline is 959triggered by a pull request: 960 961- `TANGLED_PR_SOURCE_BRANCH` - The source branch of the pull 962 request 963- `TANGLED_PR_TARGET_BRANCH` - The target branch of the pull 964 request 965- `TANGLED_PR_SOURCE_SHA` - The commit SHA of the source 966 branch 967 968### Steps 969 970The `steps` field allows you to define what steps should run 971in the workflow. It's a list of step objects, each with the 972following fields: 973 974- `name`: This field allows you to give your step a name. 975 This name is visible in your workflow runs, and is used to 976 describe what the step is doing. 977- `command`: This field allows you to define a command to 978 run in that step. The step is run in a Bash shell, and the 979 logs from the command will be visible in the pipelines 980 page on the Tangled website. Any dependencies you added in 981 your engine's section (see [Engines](#engines)) will be 982 available to use here. 983- `environment`: Similar to the global 984 [environment](#environment) config, this **optional** 985 field is a key-value map that allows you to set 986 environment variables for the step. **Do not put secrets 987 here, these environment variables are visible to anyone 988 viewing the repository. You can add secrets for pipelines 989 in your repository's settings.** 990 991Example: 992 993```yaml 994steps: 995 - name: "Build backend" 996 command: "go build" 997 environment: 998 GOOS: "darwin" 999 GOARCH: "arm64" 1000 - name: "Build frontend" 1001 command: "npm run build" 1002 environment: 1003 NODE_ENV: "production" 1004``` 1005 1006## Engines 1007 1008The common fields above apply to every workflow. Each engine 1009then adds its own fields on top. Pick an engine with the 1010[`engine`](#engine) field and use the matching section below. 1011 1012### Nixery engine 1013 1014#### Dependencies 1015 1016When you're running a workflow you'll usually need additional 1017dependencies. The `dependencies` field lets you define which 1018dependencies to get, and from where. It's a key-value map, 1019with the key being the registry to fetch dependencies from, 1020and the value being the list of dependencies to fetch. 1021 1022The registry URL syntax can be found [on the nix 1023manual](https://nix.dev/manual/nix/2.18/command-ref/new-cli/nix3-registry-add). 1024 1025Say you want to fetch Node.js and Go from `nixpkgs`, and a 1026package called `my_pkg` you've made from your own registry 1027at your repository at 1028`https://tangled.org/@example.com/my_pkg`. You can define 1029those dependencies like so: 1030 1031```yaml 1032dependencies: 1033 # nixpkgs 1034 nixpkgs: 1035 - nodejs 1036 - go 1037 # unstable 1038 nixpkgs/nixpkgs-unstable: 1039 - bun 1040 # custom registry 1041 git+https://tangled.org/@example.com/my_pkg: 1042 - my_pkg 1043``` 1044 1045Now these dependencies are available to use in your 1046workflow! 1047 1048#### Complete nixery workflow 1049 1050```yaml 1051# .tangled/workflows/build.yml 1052 1053when: 1054 - event: ["push", "manual"] 1055 branch: ["main", "develop"] 1056 - event: ["pull_request"] 1057 branch: ["main"] 1058 1059engine: "nixery" 1060 1061# using the default values 1062clone: 1063 skip: false 1064 depth: 1 1065 submodules: false 1066 1067dependencies: 1068 # nixpkgs 1069 nixpkgs: 1070 - nodejs 1071 - go 1072 # custom registry 1073 git+https://tangled.org/@example.com/my_pkg: 1074 - my_pkg 1075 1076environment: 1077 GOOS: "linux" 1078 GOARCH: "arm64" 1079 NODE_ENV: "production" 1080 MY_ENV_VAR: "MY_ENV_VALUE" 1081 1082steps: 1083 - name: "Build backend" 1084 command: "go build" 1085 environment: 1086 GOOS: "darwin" 1087 GOARCH: "arm64" 1088 - name: "Build frontend" 1089 command: "npm run build" 1090 environment: 1091 NODE_ENV: "production" 1092``` 1093 1094If you want another example of a workflow, you can look at 1095the one [Tangled uses to build the 1096project](https://tangled.org/@tangled.org/core/blob/master/.tangled/workflows/build.yml). 1097 1098### microVM engine 1099 1100#### Image 1101 1102A workflow picks the image to boot with the top-level `image` 1103field: 1104 1105```yaml 1106engine: microvm 1107image: nixos 1108``` 1109 1110There are two flavours of images: 1111 1112- **NixOS images** (e.g. `nixos`): the whole guest is built 1113 with Nix, so you can configure it from the workflow file 1114 itself. The `dependencies`, `services`, `virtualisation`, 1115 `registry` and `caches` fields below are all understood 1116 here, and the guest builds and activates that configuration 1117 before any of your steps run. 1118- **Non-NixOS images** (e.g. `alpine`): there's no NixOS to 1119 configure, so the workflow-level config fields above have 1120 no effect. You still get a full machine to run steps in. 1121 1122The available image names depend on what the spindle operator 1123has installed. `nixos` and `alpine` are examples. If `image` 1124is omitted, the spindle's configured default image is used. 1125 1126#### Dependencies 1127 1128On the microVM engine, `dependencies` is a flat list of 1129packages that are made available to every step. This field 1130only applies to **NixOS images**; for other images you can 1131use the package manager included in a step. 1132 1133The guest builds a [`nix develop`](https://nix.dev/manual/nix/2.18/command-ref/new-cli/nix3-develop)-style 1134devshell from your dependencies and uses it for each step, 1135so you can, for example, add `pkg-config` and `openssl` and 1136have the `openssl-sys` crate while compiling a Rust project 1137just work. 1138 1139A bare name like `go` is looked up in nixpkgs. You can also 1140point at any flake with the `flakeref#attr` syntax, so 1141`github:nixos/nixpkgs#hello` pulls `hello` straight out of 1142that flake. 1143 1144```yaml 1145dependencies: 1146 - go 1147 - github:nixos/nixpkgs#hello 1148``` 1149 1150#### Registry 1151 1152The `registry` field remaps flake references, the same way 1153`nix registry` does. This lets you pin or alias the flakes 1154used by `dependencies`. 1155 1156For example, pin `nixpkgs` to `nixos-unstable` so that the 1157bare `go` above resolves from unstable, and alias your own 1158flake so you can use `myflake#tool` in `dependencies`: 1159 1160```yaml 1161registry: 1162 nixpkgs: github:nixos/nixpkgs/nixos-unstable 1163 myflake: github:me/x 1164``` 1165 1166#### Caches 1167 1168The `caches` field is a map of Nix binary cache URL to its 1169trusted public key. These are fed into the spindle's read 1170proxy, so the guest can substitute prebuilt paths from them 1171instead of building everything from scratch. 1172 1173```yaml 1174caches: 1175 https://nix-community.cachix.org: "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs=" 1176``` 1177 1178#### Services and virtualisation 1179 1180The `services` and `virtualisation` fields are passed straight 1181through to NixOS. Anything you could write under 1182`services.*` or `virtualisation.*` in a NixOS configuration, 1183you can write here, and it's brought up before any of your 1184steps run. 1185 1186As a convenience, `true` works as shorthand for 1187`.enable = true` anywhere an `enable` option exists (e.g. 1188`virtualisation.docker: true`). 1189 1190```yaml 1191services: 1192 postgresql: 1193 enable: true 1194 ensureDatabases: ["spindle-workflow"] 1195 ensureUsers: 1196 - name: spindle-workflow 1197 ensureDBOwnership: true 1198 1199virtualisation: 1200 docker: true 1201``` 1202 1203#### Recipes 1204 1205##### Lint, test and build a Node project 1206 1207```yaml 1208when: 1209 - event: ["push", "pull_request"] 1210 branch: ["main"] 1211 1212engine: microvm 1213image: nixos 1214 1215dependencies: 1216 - pnpm 1217 1218steps: 1219 - name: "Install dependencies" 1220 command: pnpm install --frozen-lockfile 1221 - name: "Lint and test" 1222 command: | 1223 pnpm run lint 1224 pnpm test 1225 - name: "Build" 1226 command: pnpm run build 1227``` 1228 1229##### Check formatting 1230 1231```yaml 1232when: 1233 - event: ["push", "pull_request"] 1234 branch: ["main"] 1235 1236engine: microvm 1237image: alpine # slimmer image for checking the formatting 1238 1239steps: 1240 - name: "Install go" 1241 command: apk add go 1242 - name: "Check formatting" 1243 command: test -z $(gofmt -l .) 1244``` 1245 1246##### Build a Rust project that links OpenSSL 1247 1248```yaml 1249when: 1250 - event: ["push", "pull_request"] 1251 branch: ["main"] 1252 1253engine: microvm 1254image: nixos 1255 1256dependencies: 1257 - gcc 1258 - cargo 1259 - rustc 1260 - clippy 1261 - rustfmt 1262 - pkg-config # exports PKG_CONFIG_PATH for the libraries below 1263 - openssl # the C library + headers openssl-sys links against 1264 1265steps: 1266 - name: "Check formatting" 1267 command: cargo fmt --check 1268 - name: "Clippy" 1269 command: cargo clippy --all-targets -- -D warnings 1270 - name: "Test" 1271 command: cargo test --all 1272 - name: "Release build" 1273 command: cargo build --release 1274``` 1275 1276##### Run migrations and integration tests against PostgreSQL 1277 1278```yaml 1279when: 1280 - event: ["push", "pull_request"] 1281 branch: ["main"] 1282 1283engine: microvm 1284image: nixos 1285 1286environment: 1287 DATABASE_URL: "postgresql:///spindle-workflow?host=/run/postgresql" 1288 1289dependencies: 1290 - gcc 1291 - cargo 1292 - rustc 1293 - pkg-config 1294 - openssl 1295 - sqlx-cli 1296 1297services: 1298 postgresql: 1299 enable: true 1300 # has to be same name as the user for peer auth to work automatically 1301 ensureDatabases: ["spindle-workflow"] 1302 ensureUsers: 1303 - name: spindle-workflow 1304 ensureDBOwnership: true 1305 1306steps: 1307 - name: "Run migrations" 1308 command: sqlx migrate run 1309 - name: "Integration tests" 1310 command: cargo test --all 1311``` 1312 1313##### Build and push a Docker image on tag 1314 1315```yaml 1316when: 1317 - event: ["push"] 1318 tag: ["v*"] 1319 1320engine: microvm 1321image: nixos 1322 1323virtualisation: 1324 docker: true 1325 1326steps: 1327 - name: "Build and push to ghcr.io" 1328 command: | 1329 set -euo pipefail 1330 1331 echo "$REGISTRY_TOKEN" | docker login ghcr.io -u "$REGISTRY_USER" --password-stdin 1332 image="ghcr.io/$REGISTRY_USER/myapp:$TANGLED_REF_NAME" 1333 1334 docker build -t "$image" -t "ghcr.io/$REGISTRY_USER/myapp:latest" . 1335 docker push "$image" 1336 docker push "ghcr.io/$REGISTRY_USER/myapp:latest" 1337``` 1338 1339##### Deploy to Cloudflare Workers on tag 1340 1341```yaml 1342# .tangled/workflows/deploy.yml 1343when: 1344 - event: ["push"] 1345 tag: ["v*"] 1346 1347engine: microvm 1348image: nixos 1349 1350dependencies: 1351 - pnpm 1352 1353steps: 1354 - name: "Install dependencies" 1355 command: pnpm install --frozen-lockfile 1356 - name: "Deploy worker" 1357 # `wrangler` picks up `CLOUDFLARE_API_TOKEN` from the env. 1358 # set it under **Settings → Secrets**. 1359 command: pnpm exec wrangler deploy 1360``` 1361 1362##### Publish a release artifact 1363 1364```yaml 1365when: 1366 - event: ["push"] 1367 tag: ["v*"] # trigger on versions 1368 1369engine: microvm 1370image: nixos 1371 1372dependencies: 1373 - go 1374 1375steps: 1376 - name: "Build release binary" 1377 command: | 1378 mkdir -p dist 1379 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o dist/myapp ./cmd/myapp 1380 1381 - name: "Publish artifact record" 1382 command: | 1383 set -euo pipefail 1384 # change this if you're not on `tngl.sh` 1385 PDS="https://tngl.sh" 1386 # also update this to your handle or did 1387 ATP_IDENTIFIER="user.tngl.sh" 1388 ARTIFACT_PATH="dist/myapp" 1389 ARTIFACT_NAME="myapp" 1390 1391 # set `ATP_APP_PASSWORD` under **Settings → Secrets** 1392 session=$(curl -fsS -X POST "$PDS/xrpc/com.atproto.server.createSession" \ 1393 -H "Content-Type: application/json" \ 1394 -d "{\"identifier\":\"$ATP_IDENTIFIER\",\"password\":\"$ATP_APP_PASSWORD\"}") 1395 jwt=$(echo "$session" | jq -r .accessJwt) 1396 did=$(echo "$session" | jq -r .did) 1397 1398 # upload the binary as a blob 1399 blob=$(curl -fsS -X POST "$PDS/xrpc/com.atproto.repo.uploadBlob" \ 1400 -H "Authorization: Bearer $jwt" \ 1401 -H "Content-Type: application/octet-stream" \ 1402 --data-binary @"$ARTIFACT_PATH") 1403 1404 # note that this requires an annotated tag (`git tag -a v1.0.0 -m ...`) 1405 tag_hash=$(git rev-parse "$TANGLED_REF_NAME^{tag}") 1406 tag_bytes=$(printf '%s' "$tag_hash" | xxd -r -p | base64 | tr -d '=') 1407 1408 # the sh.tangled.repo.artifact record for your artifact 1409 record=$(jq -n \ 1410 --arg did "$did" \ 1411 --arg tag "$tag_bytes" \ 1412 --arg name "$ARTIFACT_NAME" \ 1413 --arg repo "$TANGLED_REPO_URL" \ 1414 --arg created "$(date -Iseconds)" \ 1415 --argjson blob "$(echo "$blob" | jq .blob)" '{ 1416 repo: $did, 1417 collection: "sh.tangled.repo.artifact", 1418 validate: false, 1419 record: { 1420 "$type": "sh.tangled.repo.artifact", 1421 tag: {"$bytes": $tag}, 1422 name: $name, 1423 repo: $repo, 1424 artifact: $blob, 1425 createdAt: $created 1426 } 1427 }') 1428 1429 # create the record on the PDS 1430 curl -fsS -X POST "$PDS/xrpc/com.atproto.repo.createRecord" \ 1431 -H "Authorization: Bearer $jwt" \ 1432 -H "Content-Type: application/json" \ 1433 -d "$record" 1434``` 1435 1436## Self-hosting guide 1437 1438### Prerequisites 1439 1440- Go 1441- For the **nixery** engine: Docker (or Podman with Docker 1442 compatibility enabled). 1443- For the **microVM** engine: a Linux host with KVM, plus the 1444 microVM host dependencies described in [Running microVM 1445 workflows](#running-microvm-workflows). 1446 1447### Configuration 1448 1449Spindle is configured using environment variables. The following environment variables are available: 1450 1451- `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`). 1452- `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`). 1453- `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required). 1454- `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`). 1455- `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`). 1456- `SPINDLE_SERVER_OWNER`: The DID of the owner (required). 1457- `SPINDLE_SERVER_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`). 1458- `SPINDLE_SERVER_DOCKER_SOCKET`: Path to Docker socket to expose to invoked Spindle containers (default: `""`). 1459- `SPINDLE_NIXERY_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`). 1460- `SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`). 1461 1462For the microVM engine, the following are also available 1463(prefix `SPINDLE_MICROVM_PIPELINES_`): 1464 1465- `SPINDLE_MICROVM_PIPELINES_IMAGE_DIR`: Directory containing 1466 microVM images (**required** to use the engine). See 1467 [Running microVM workflows](#running-microvm-workflows). 1468- `SPINDLE_MICROVM_PIPELINES_DEFAULT_IMAGE`: Image used when a 1469 workflow doesn't set `image` (default: `"nixos-x86_64"`). 1470- `SPINDLE_MICROVM_PIPELINES_OVERLAY_DIR`: Where per-workflow 1471 temporary disks are created (default: the system temp dir). 1472- `SPINDLE_MICROVM_PIPELINES_ENABLE_KVM`: Use KVM hardware 1473 acceleration (default: `true`). Without KVM, guests fall 1474 back to slow software emulation. 1475- `SPINDLE_MICROVM_PIPELINES_WORKFLOW_TIMEOUT`: Default 1476 workflow timeout (default: `"5m"`). 1477 1478Optional resource limits (a value of `0` disables that 1479limit). The limits cap usage across all running microVM 1480workflows: 1481 1482- `SPINDLE_MICROVM_PIPELINES_MAX_TOTAL_MEMORY_MIB` 1483- `SPINDLE_MICROVM_PIPELINES_MAX_TOTAL_VCPUS` 1484- `SPINDLE_MICROVM_PIPELINES_MAX_TOTAL_DISK_MIB` 1485 1486Optional cgroup enforcement: 1487 1488- `SPINDLE_MICROVM_PIPELINES_ENABLE_CGROUPS`: Place each 1489 workflow's QEMU and slirp4netns in a per-workflow cgroup= 1490 (default: `false`). 1491- `SPINDLE_MICROVM_PIPELINES_CGROUP_PARENT`: Parent cgroup; 1492 `self` resolves the spindle service's own cgroup (default: 1493 `"self"`). 1494- `SPINDLE_MICROVM_PIPELINES_CGROUP_PIDS_MAX`: Max processes 1495 per workflow cgroup (default: `4096`). 1496- `SPINDLE_MICROVM_PIPELINES_CGROUP_SWAP_MAX_MIB`: Max swap 1497 per workflow cgroup (default: `0`, no swap). 1498- `SPINDLE_MICROVM_PIPELINES_CGROUP_CPU_MAX_PERCENT`: Max CPU 1499 quota per workflow cgroup, as a percentage of one core 1500 (default: `0`, which caps each VM at its configured vCPU 1501 count, a negative value disables the CPU limit). 1502- `SPINDLE_MICROVM_PIPELINES_CGROUP_IO_WEIGHT`: IO weight per 1503 workflow cgroup, 1-10000 (default: `0`, IO unlimited). 1504- `SPINDLE_MICROVM_PIPELINES_CGROUP_SUPERVISOR_MEMORY_MIN_MIB`: 1505 Memory protected for spindle itself so it isn't OOM-killed 1506 before the workflows (default: `512`). 1507 1508To push paths built inside microVMs back to a shared Nix 1509cache (and read from it), configure the cache (prefix 1510`SPINDLE_NIX_CACHE_`): 1511 1512- `SPINDLE_NIX_CACHE_READ_URLS`: Comma-separated binary cache 1513 URLs the guest reads from. 1514- `SPINDLE_NIX_CACHE_TRUSTED_PUBLIC_KEYS`: Comma-separated 1515 trusted public keys for those caches. 1516- `SPINDLE_NIX_CACHE_UPLOAD_URL`: Cache URL that paths built 1517 in the guest are uploaded to. 1518 1519### Running spindle 1520 15211. **Set the environment variables.** For example: 1522 1523 ```shell 1524 export SPINDLE_SERVER_HOSTNAME="your-hostname" 1525 export SPINDLE_SERVER_OWNER="your-did" 1526 ``` 1527 15282. **Build the Spindle binary.** 1529 1530 ```shell 1531 cd core 1532 go mod download 1533 go build -o cmd/spindle/spindle cmd/spindle/main.go 1534 ``` 1535 15363. **Create the log directory.** 1537 1538 ```shell 1539 sudo mkdir -p /var/log/spindle 1540 sudo chown $USER:$USER -R /var/log/spindle 1541 ``` 1542 15434. **Run the Spindle binary.** 1544 1545 ```shell 1546 ./cmd/spindle/spindle 1547 ``` 1548 1549Spindle will now start, connect to the Jetstream server, and begin processing pipelines. 1550 1551### Running microVM workflows 1552 1553The microVM engine needs a few extra things on the host, and 1554it needs images to boot. 1555 1556#### Host dependencies 1557 1558microVM workflows depend on a handful of host tools and 1559devices. spindle checks for the ones an image needs right 1560before it launches, so a missing dependency surfaces as a 1561clear error. You'll need: 1562 1563- `qemu`: the runner. The QEMU binary for the image's arch 1564 must be present (e.g. `qemu-system-x86_64`). 1565- `mkfs.ext4` (from `e2fsprogs`): to format the per-workflow 1566 writable volumes. 1567- [`slirp4netns`](https://github.com/rootless-containers/slirp4netns#install), 1568 `ip` (from `iproute2`), `mount` and `unshare` (from `util-linux`): 1569 used to sandbox guest networking. 1570- `/dev/kvm`: for hardware acceleration (unless you disable 1571 KVM with `SPINDLE_MICROVM_PIPELINES_ENABLE_KVM=false`). 1572- `/dev/vhost-vsock`: used by QEMU to enable guest-to-host vsock 1573 communication. 1574- `/dev/vsock`: used by spindle on the host to listen on vsock ports 1575 and accept guest agent connections. 1576- `/dev/net/tun`: required by `slirp4netns` to set up tap devices 1577 for sandboxed guest networking. 1578 1579On NixOS, the [spindle 1580module](https://tangled.org/tangled.org/core/blob/master/nix/modules/spindle.nix) 1581puts `qemu`, `e2fsprogs`, `slirp4netns`, `iproute2` and 1582`util-linux` on the service's `PATH` for you. 1583 1584#### Container virtualization 1585 1586Running `spindle` inside a container (e.g., Docker, Podman, or LXD) requires passing host device nodes into the container and granting the runtime additional privileges. Because spindle uses nested namespaces (`unshare`) and helper tools (`slirp4netns`), the container configuration will require: 1587 1588- `/dev/vsock`, `/dev/vhost-vsock`, `/dev/kvm`, and `/dev/net/tun` mapped from the host. 1589- `NET_ADMIN` and `SYS_ADMIN` capabilities to manage network namespaces and mounts inside the container. 1590- Relaxed seccomp filters (e.g., `seccomp=unconfined`) and SELinux/AppArmor containment if they restrict namespace creation or device access. 1591 1592#### Building images 1593 1594Images are built with Nix. The flake exposes packages for the 1595two stock images (use the `-tarball` prefixed ones for a gzipped 1596tarball you can copy to another host): 1597 1598```shell 1599# a NixOS image 1600nix build .#spindle-nixos-image 1601# an Alpine image 1602nix build .#spindle-alpine-image 1603``` 1604 1605#### Installing images 1606 1607Spindle looks for images in 1608`SPINDLE_MICROVM_PIPELINES_IMAGE_DIR`. An image is resolved by 1609the name a workflow puts in its `image` field, matched 1610literally against what's on disk: 1611 16121. a directory `<name>/` containing a `spec.json` (next to the 1613 kernel/initrd/store-disk), or 16142. a flat `<name>.json` self-contained spec. 1615 1616Resolution depends only on the name and what's on disk, never 1617on the host doing the resolving, so the same workflow resolves 1618to the same image on every spindle. If you keep multiple 1619arches side by side, you can name them `<name>-<arch>` (e.g. 1620`nixos-x86_64`, `alpine-aarch64`); the suffix is just part of 1621the name. To make a name like `nixos` work if you are hosting 1622multiple arches, you can use symlinks. 1623 1624On NixOS, you'll most likely want to use `systemd.tmpfiles.rules` 1625to set these up declaratively. 1626 1627## Architecture 1628 1629Spindle is a small CI runner service. Here's a high-level overview of how it operates: 1630 1631- Listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and 1632 [`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream. 1633- When a new repo record comes through (typically when you add a spindle to a 1634 repo from the settings), spindle then resolves the underlying knot and 1635 subscribes to repo events (see: 1636 [`sh.tangled.pipeline`](/lexicons/pipeline.json)). 1637- The spindle engine then handles execution of the pipeline, with results and 1638 logs beamed on the spindle event stream over WebSocket 1639 1640### The engines 1641 1642Spindle has two execution backends, picked per-workflow with 1643the [`engine`](#engine) field: 1644 1645- **nixery**: executes each step in a fresh Docker container 1646 (Podman works too, if Docker compatibility is enabled so 1647 that `/run/docker.sock` is created), with state persisted 1648 across steps within the `/tangled/workspace` directory. The 1649 base image for the container is constructed on the fly using 1650 [Nixery](https://nixery.dev), which is/rhandy for caching 1651 layers for frequently used packages. 1652- **microvm**: runs the whole workflow inside its own 1653 microVM, supporting different images, with extra 1654 configuration for NixOS images (e.g. services in workflow file) 1655 See the [engine 1656 README](https://tangled.org/tangled.org/core/blob/master/spindle/engines/microvm/README.md) 1657 for the architecture in depth. 1658 1659The pipeline manifest is [specified here](https://docs.tangled.org/spindles.html#pipelines). 1660 1661## Secrets with openbao 1662 1663This document covers setting up spindle to use OpenBao for secrets 1664management via OpenBao Proxy instead of the default SQLite backend. 1665 1666### Overview 1667 1668Spindle now uses OpenBao Proxy for secrets management. The proxy handles 1669authentication automatically using AppRole credentials, while spindle 1670connects to the local proxy instead of directly to the OpenBao server. 1671 1672This approach provides better security, automatic token renewal, and 1673simplified application code. 1674 1675### Installation 1676 1677Install OpenBao from Nixpkgs: 1678 1679```bash 1680nix shell nixpkgs#openbao # for a local server 1681``` 1682 1683### Setup 1684 1685The setup process can is documented for both local development and production. 1686 1687#### Local development 1688 1689Start OpenBao in dev mode: 1690 1691```bash 1692bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201 1693``` 1694 1695This starts OpenBao on `http://localhost:8201` with a root token. 1696 1697Set up environment for bao CLI: 1698 1699```bash 1700export BAO_ADDR=http://localhost:8200 1701export BAO_TOKEN=root 1702``` 1703 1704#### Production 1705 1706You would typically use a systemd service with a 1707configuration file. Refer to 1708[@tangled.org/infra](https://tangled.org/@tangled.org/infra) 1709for how this can be achieved using Nix. 1710 1711Then, initialize the bao server: 1712 1713```bash 1714bao operator init -key-shares=1 -key-threshold=1 1715``` 1716 1717This will print out an unseal key and a root key. Save them 1718somewhere (like a password manager). Then unseal the vault 1719to begin setting it up: 1720 1721```bash 1722bao operator unseal <unseal_key> 1723``` 1724 1725All steps below remain the same across both dev and 1726production setups. 1727 1728#### Configure openbao server 1729 1730Create the spindle KV mount: 1731 1732```bash 1733bao secrets enable -path=spindle -version=2 kv 1734``` 1735 1736Set up AppRole authentication and policy: 1737 1738Create a policy file `spindle-policy.hcl`: 1739 1740```hcl 1741# Full access to spindle KV v2 data 1742path "spindle/data/*" { 1743 capabilities = ["create", "read", "update", "delete"] 1744} 1745 1746# Access to metadata for listing and management 1747path "spindle/metadata/*" { 1748 capabilities = ["list", "read", "delete", "update"] 1749} 1750 1751# Allow listing at root level 1752path "spindle/" { 1753 capabilities = ["list"] 1754} 1755 1756# Required for connection testing and health checks 1757path "auth/token/lookup-self" { 1758 capabilities = ["read"] 1759} 1760``` 1761 1762Apply the policy and create an AppRole: 1763 1764```bash 1765bao policy write spindle-policy spindle-policy.hcl 1766bao auth enable approle 1767bao write auth/approle/role/spindle \ 1768 token_policies="spindle-policy" \ 1769 token_ttl=1h \ 1770 token_max_ttl=4h \ 1771 bind_secret_id=true \ 1772 secret_id_ttl=0 \ 1773 secret_id_num_uses=0 1774``` 1775 1776Get the credentials: 1777 1778```bash 1779# Get role ID (static) 1780ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id) 1781 1782# Generate secret ID 1783SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id) 1784 1785echo "Role ID: $ROLE_ID" 1786echo "Secret ID: $SECRET_ID" 1787``` 1788 1789#### Create proxy configuration 1790 1791Create the credential files: 1792 1793```bash 1794# Create directory for OpenBao files 1795mkdir -p /tmp/openbao 1796 1797# Save credentials 1798echo "$ROLE_ID" > /tmp/openbao/role-id 1799echo "$SECRET_ID" > /tmp/openbao/secret-id 1800chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id 1801``` 1802 1803Create a proxy configuration file `/tmp/openbao/proxy.hcl`: 1804 1805```hcl 1806# OpenBao server connection 1807vault { 1808 address = "http://localhost:8200" 1809} 1810 1811# Auto-Auth using AppRole 1812auto_auth { 1813 method "approle" { 1814 mount_path = "auth/approle" 1815 config = { 1816 role_id_file_path = "/tmp/openbao/role-id" 1817 secret_id_file_path = "/tmp/openbao/secret-id" 1818 } 1819 } 1820 1821 # Optional: write token to file for debugging 1822 sink "file" { 1823 config = { 1824 path = "/tmp/openbao/token" 1825 mode = 0640 1826 } 1827 } 1828} 1829 1830# Proxy listener for spindle 1831listener "tcp" { 1832 address = "127.0.0.1:8201" 1833 tls_disable = true 1834} 1835 1836# Enable API proxy with auto-auth token 1837api_proxy { 1838 use_auto_auth_token = true 1839} 1840 1841# Enable response caching 1842cache { 1843 use_auto_auth_token = true 1844} 1845 1846# Logging 1847log_level = "info" 1848``` 1849 1850#### Start the proxy 1851 1852Start OpenBao Proxy: 1853 1854```bash 1855bao proxy -config=/tmp/openbao/proxy.hcl 1856``` 1857 1858The proxy will authenticate with OpenBao and start listening on 1859`127.0.0.1:8201`. 1860 1861#### Configure spindle 1862 1863Set these environment variables for spindle: 1864 1865```bash 1866export SPINDLE_SERVER_SECRETS_PROVIDER=openbao 1867export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201 1868export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle 1869``` 1870 1871On startup, spindle will now connect to the local proxy, 1872which handles all authentication automatically. 1873 1874### Production setup for proxy 1875 1876For production, you'll want to run the proxy as a service: 1877 1878Place your production configuration in 1879`/etc/openbao/proxy.hcl` with proper TLS settings for the 1880vault connection. 1881 1882### Verifying setup 1883 1884Test the proxy directly: 1885 1886```bash 1887# Check proxy health 1888curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health 1889 1890# Test token lookup through proxy 1891curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self 1892``` 1893 1894Test OpenBao operations through the server: 1895 1896```bash 1897# List all secrets 1898bao kv list spindle/ 1899 1900# Add a test secret via the spindle API, then check it exists 1901bao kv list spindle/repos/ 1902 1903# Get a specific secret 1904bao kv get spindle/repos/your_repo_path/SECRET_NAME 1905``` 1906 1907### How it works 1908 1909- Spindle connects to OpenBao Proxy on localhost (typically 1910 port 8200 or 8201) 1911- The proxy authenticates with OpenBao using AppRole 1912 credentials 1913- All spindle requests go through the proxy, which injects 1914 authentication tokens 1915- Secrets are stored at 1916 `spindle/repos/{sanitized_repo_path}/{secret_key}` 1917- Repository paths like `did:plc:alice/myrepo` become 1918 `did_plc_alice_myrepo` 1919- The proxy handles all token renewal automatically 1920- Spindle no longer manages tokens or authentication 1921 directly 1922 1923### Troubleshooting 1924 1925**Connection refused**: Check that the OpenBao Proxy is 1926running and listening on the configured address. 1927 1928**403 errors**: Verify the AppRole credentials are correct 1929and the policy has the necessary permissions. 1930 1931**404 route errors**: The spindle KV mount probably doesn't 1932exist—run the mount creation step again. 1933 1934**Proxy authentication failures**: Check the proxy logs and 1935verify the role-id and secret-id files are readable and 1936contain valid credentials. 1937 1938**Secret not found after writing**: This can indicate policy 1939permission issues. Verify the policy includes both 1940`spindle/data/*` and `spindle/metadata/*` paths with 1941appropriate capabilities. 1942 1943Check proxy logs: 1944 1945```bash 1946# If running as systemd service 1947journalctl -u openbao-proxy -f 1948 1949# If running directly, check the console output 1950``` 1951 1952Test AppRole authentication manually: 1953 1954```bash 1955bao write auth/approle/login \ 1956 role_id="$(cat /tmp/openbao/role-id)" \ 1957 secret_id="$(cat /tmp/openbao/secret-id)" 1958``` 1959 1960# Webhooks 1961 1962Webhooks allow you to receive HTTP POST notifications when events occur in your repositories. This enables you to integrate Tangled with external services, trigger CI/CD pipelines, send notifications, or automate workflows. 1963 1964## Overview 1965 1966Webhooks send HTTP POST requests to URLs you configure whenever specific events happen. Currently, Tangled supports push, repository rename, and pull request events, with more event types coming soon. 1967 1968## Configuring webhooks 1969 1970To set up a webhook for your repository: 1971 19721. Navigate to your repository 19732. Go to **Settings → Hooks** 19743. Click **new webhook** 19754. Configure your webhook: 1976 - **Payload URL**: The endpoint that will receive the webhook POST requests 1977 - **Secret**: An optional secret key for verifying webhook authenticity (leave blank to send unsigned webhooks) 1978 - **Events**: Select which events trigger the webhook 1979 - **Active**: Toggle whether the webhook is enabled 1980 1981## Webhook payload 1982 1983### Push 1984 1985When a push event occurs, Tangled sends a POST request with a JSON payload of the format: 1986 1987```json 1988{ 1989 "after": "7b320e5cbee2734071e4310c1d9ae401d8f6cab5", 1990 "before": "c04ddf64eddc90e4e2a9846ba3b43e67a0e2865e", 1991 "pusher": { 1992 "did": "did:plc:hwevmowznbiukdf6uk5dwrrq" 1993 }, 1994 "ref": "refs/heads/main", 1995 "repository": { 1996 "clone_url": "https://tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo", 1997 "created_at": "2025-09-15T08:57:23Z", 1998 "description": "an example repository", 1999 "fork": false, 2000 "full_name": "did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo", 2001 "html_url": "https://tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo", 2002 "name": "some-repo", 2003 "open_issues_count": 5, 2004 "owner": { 2005 "did": "did:plc:hwevmowznbiukdf6uk5dwrrq" 2006 }, 2007 "ssh_url": "ssh://git@tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo", 2008 "stars_count": 1, 2009 "updated_at": "2025-09-15T08:57:23Z" 2010 } 2011} 2012``` 2013 2014### Pull request 2015 2016Pull request events are sent as separate event types, so you can subscribe to 2017exactly the transitions you care about: 2018 2019- `pull_request:created` — a pull request was opened 2020- `pull_request:resubmitted` — a new round (revision) was pushed to a pull request 2021- `pull_request:merged` — a pull request was merged 2022- `pull_request:closed` — a pull request was closed 2023- `pull_request:reopened` — a closed pull request was reopened 2024 2025All pull request events share the same payload format: 2026 2027```json 2028{ 2029 "action": "created", 2030 "pull_request": { 2031 "number": 4, 2032 "title": "add dark mode", 2033 "body": "implements dark mode as discussed in #2", 2034 "state": "open", 2035 "target_branch": "main", 2036 "source": { 2037 "branch": "dark-mode", 2038 "sha": "7b320e5cbee2734071e4310c1d9ae401d8f6cab5" 2039 }, 2040 "round_number": 0, 2041 "owner": { 2042 "did": "did:plc:hwevmowznbiukdf6uk5dwrrq" 2043 }, 2044 "html_url": "https://tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo/pulls/4", 2045 "patch_url": "https://tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo/pulls/4/round/0.patch", 2046 "created_at": "2025-09-15T08:57:23Z" 2047 }, 2048 "repository": { ... }, 2049 "sender": { 2050 "did": "did:plc:hwevmowznbiukdf6uk5dwrrq" 2051 } 2052} 2053``` 2054 2055Notes: 2056 2057- `action` mirrors the event type suffix (`created`, `resubmitted`, `merged`, `closed`, `reopened`). 2058- `repository` has the same format as in the push payload. 2059- The patch itself is not embedded in the payload (patches can be large); fetch it from `patch_url` instead. `round_number` identifies the latest round, and `patch_url` always points at that round's patch. 2060- `source` is only present for branch-based and fork-based pull requests; it is omitted for patch-based pulls. For fork-based pulls, `source.repo` contains the DID of the source repository. 2061- `sender` is the user who performed the action. 2062 2063## HTTP headers 2064 2065Each webhook request includes the following headers: 2066 2067- `Content-Type: application/json` 2068- `User-Agent: Tangled-Hook/<short-sha>` — User agent with short SHA of the commit (push events); `Tangled-Hook/pull_request` for pull request events 2069- `X-Tangled-Event: push` — The full event type (e.g. `push`, `pull_request:merged`) 2070- `X-Tangled-Hook-ID: <webhook-id>` — The webhook ID 2071- `X-Tangled-Delivery: <uuid>` — Unique delivery ID 2072- `X-Tangled-Signature-256: sha256=<hmac>` — HMAC-SHA256 signature (if secret configured) 2073 2074## Verifying webhook signatures 2075 2076If you configured a secret, you should verify the webhook signature to ensure requests are authentic. For example, in Go: 2077 2078```go 2079package main 2080 2081import ( 2082 "crypto/hmac" 2083 "crypto/sha256" 2084 "encoding/hex" 2085 "io" 2086 "net/http" 2087 "strings" 2088) 2089 2090func verifySignature(payload []byte, signatureHeader, secret string) bool { 2091 // Remove 'sha256=' prefix from signature header 2092 signature := strings.TrimPrefix(signatureHeader, "sha256=") 2093 2094 // Compute expected signature 2095 mac := hmac.New(sha256.New, []byte(secret)) 2096 mac.Write(payload) 2097 expected := hex.EncodeToString(mac.Sum(nil)) 2098 2099 // Use constant-time comparison to prevent timing attacks 2100 return hmac.Equal([]byte(signature), []byte(expected)) 2101} 2102 2103func webhookHandler(w http.ResponseWriter, r *http.Request) { 2104 // Read the request body 2105 payload, err := io.ReadAll(r.Body) 2106 if err != nil { 2107 http.Error(w, "Bad request", http.StatusBadRequest) 2108 return 2109 } 2110 2111 // Get signature from header 2112 signatureHeader := r.Header.Get("X-Tangled-Signature-256") 2113 2114 // Verify signature 2115 if signatureHeader != "" && verifySignature(payload, signatureHeader, yourSecret) { 2116 // Webhook is authentic, process it 2117 processWebhook(payload) 2118 w.WriteHeader(http.StatusOK) 2119 } else { 2120 http.Error(w, "Invalid signature", http.StatusUnauthorized) 2121 } 2122} 2123``` 2124 2125## Delivery retries 2126 2127Webhooks are automatically retried on failure: 2128 2129- **3 total attempts** (1 initial + 2 retries) 2130- **Exponential backoff** starting at 1 second, max 10 seconds 2131- **Retried on**: 2132 - Network errors 2133 - HTTP 5xx server errors 2134- **Not retried on**: 2135 - HTTP 4xx client errors (bad request, unauthorized, etc.) 2136 2137### Timeouts 2138 2139Webhook requests timeout after 30 seconds. If your endpoint needs more time: 2140 21411. Respond with 200 OK immediately 21422. Process the webhook asynchronously in the background 2143 2144## Example integrations 2145 2146### Discord notifications 2147 2148```javascript 2149app.post("/webhook", (req, res) => { 2150 const payload = req.body; 2151 2152 fetch("https://discord.com/api/webhooks/...", { 2153 method: "POST", 2154 headers: { "Content-Type": "application/json" }, 2155 body: JSON.stringify({ 2156 content: `New push to ${payload.repository.full_name}`, 2157 embeds: [ 2158 { 2159 title: `${payload.pusher.did} pushed to ${payload.ref}`, 2160 url: payload.repository.html_url, 2161 color: 0x00ff00, 2162 }, 2163 ], 2164 }), 2165 }); 2166 2167 res.status(200).send("OK"); 2168}); 2169``` 2170 2171# Migrating knots and spindles 2172 2173Sometimes, non-backwards compatible changes are made to the 2174knot/spindle XRPC APIs. If you host a knot or a spindle, you 2175will need to follow this guide to upgrade. Typically, this 2176only requires you to deploy the newest version. 2177 2178This document is laid out in reverse-chronological order. 2179Newer migration guides are listed first, and older guides 2180are further down the page. 2181 2182## Upgrading to v1.16.0-alpha 2183 2184Starting with v1.16.0-alpha, spindles own CI pipeline data 2185directly. The appview no longer stores pipeline runs or follows 2186spindle event streams for pipeline history. Instead, it asks the 2187configured spindle for pipeline lists, single pipeline details, 2188workflow logs, retries, and cancellations over XRPC. 2189 2190This means that existing pipeline logs / runs won't appear after 2191you upgrade. Existing pipeline history from the appview cannot be 2192automatically migrated. If you want to migrate your data, you can 2193reach out to us and we will send you an SQL file that'll add the data 2194into your spindle. 2195 2196- Upgrade to the latest tag (v1.16.0 or above) 2197- Head to the [spindle 2198 dashboard](https://tangled.org/settings/spindles) and hit the 2199 "retry" button to verify your spindle 2200 2201## Upgrading to v1.15.0-alpha 2202 2203With v1.15.0-alpha, a knot itself owns its members and 2204per-repo collaborators directly. Previously this data was sourced from 2205PDS records (`sh.tangled.knot.member` and `sh.tangled.repo.collaborator`) 2206that the appview and the knot both read off the firehose. 2207The knot is now the source of truth and serves them over XRPC instead: 2208 2209- `sh.tangled.knot.addMember`, `sh.tangled.knot.removeMember`, `sh.tangled.knot.listMembers` 2210- `sh.tangled.repo.addCollaborator`, `sh.tangled.repo.removeCollaborator`, `sh.tangled.repo.listCollaborators` 2211 2212Until your knot is upgraded, the appview keeps reading its 2213members and collaborators from the old firehose-sourced records. 2214Upgrade to move your knot onto knot-owned access control. 2215 2216- Upgrade to the latest tag (v1.15.0 or above) 2217- Head to the [knot dashboard](https://tangled.org/settings/knots) and 2218 hit the "retry" button to verify your knot 2219 2220## Upgrading to v1.14.0-alpha 2221 2222Starting with v1.14.0-alpha, the fully knot uses the repoDID as its 2223canonical handle for repositories. This unlocks repository 2224renames from the appview UI and changes the wire format for 2225the following lexicons (`sh.tangled.repo.pull`, `sh.tangled.repo.collaborator`, 2226`sh.tangled.repo.issue`, `sh.tangled.git.refUpdate`). 2227 2228Knots that have not been upgraded may silently drop new push 2229events, pull requests, issues, and collaborator invites for 2230repositories they host until upgraded. So upgrade please!!! 2231 2232- Upgrade to the latest tag (v1.14.0 or above) 2233- Head to the [knot dashboard](https://tangled.org/settings/knots) and 2234 hit the "retry" button to verify your knot 2235 2236## Upgrading to v1.13.0-alpha 2237 2238Starting with v1.13.0-alpha, every repository on a knot is 2239assigned a DID. This makes repositories stable across 2240renames and transfers. 2241 2242When you upgrade your knot to this version, the server will 2243automatically mint DIDs for all existing repositories on 2244startup. This is a one-time process and you may see 2245additional log output during the first boot as DIDs are 2246assigned. 2247 2248- Upgrade to the latest tag (v1.13.0 or above) 2249- Head to the [knot dashboard](https://tangled.org/settings/knots) and 2250 hit the "retry" button to verify your knot 2251 2252## Upgrading from v1.8.x 2253 2254After v1.8.2, the HTTP API for knots and spindles has been 2255deprecated and replaced with XRPC. Repositories on outdated 2256knots will not be viewable from the appview. Upgrading is 2257straightforward however. 2258 2259For knots: 2260 2261- Upgrade to the latest tag (v1.9.0 or above) 2262- Head to the [knot dashboard](https://tangled.org/settings/knots) and 2263 hit the "retry" button to verify your knot 2264 2265For spindles: 2266 2267- Upgrade to the latest tag (v1.9.0 or above) 2268- Head to the [spindle 2269 dashboard](https://tangled.org/settings/spindles) and hit the 2270 "retry" button to verify your spindle 2271 2272## Upgrading from v1.7.x 2273 2274After v1.7.0, knot secrets have been deprecated. You no 2275longer need a secret from the appview to run a knot. All 2276authorized commands to knots are managed via [Inter-Service 2277Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 2278Knots will be read-only until upgraded. 2279 2280Upgrading is quite easy, in essence: 2281 2282- `KNOT_SERVER_SECRET` is no more, you can remove this 2283 environment variable entirely 2284- `KNOT_SERVER_OWNER` is now required on boot, set this to 2285 your DID. You can find your DID in the 2286 [settings](https://tangled.org/settings) page. 2287- Restart your knot once you have replaced the environment 2288 variable 2289- Head to the [knot dashboard](https://tangled.org/settings/knots) and 2290 hit the "retry" button to verify your knot. This simply 2291 writes a `sh.tangled.knot` record to your PDS. 2292 2293If you use the nix module, simply bump the flake to the 2294latest revision, and change your config block like so: 2295 2296```diff 2297 services.tangled.knot = { 2298 enable = true; 2299 server = { 2300- secretFile = /path/to/secret; 2301+ owner = "did:plc:foo"; 2302 }; 2303 }; 2304``` 2305 2306# Bobbin 2307 2308Bobbin is an API appview for Tangled records. It serves XRPC 2309endpoints for `sh.tangled.*`, with it you can get repos, 2310issues, pulls, comments, follows, stars, labels, pipelines, 2311and profiles. It is read-only, there is no auth, since that 2312should all be handled direct-to-PDS and knot respectively. 2313 2314**Bobbin has no permanent storage**. 2315 2316It is only a glorified edge index, in the graph theory 2317sense. Additionally it has a record cache, re-filled on 2318demand. All other data that Bobbin serves comes live from 2319PDSes & knots. 2320 2321## What Bobbin needs 2322 2323The way that Bobbin is able to pull off being 2324so stateless is by moving state upstream. 2325Primarily it depends on an instance of 2326[Hydrant](https://tangled.org/did:plc:6v3ul2ptnqctyxwkz5ti4amn) 2327, which is the service that gives an event stream 2328for Bobbin to quickly backfill from on every restart. 2329Backfilling ought to take less than a couple of minutes 2330maximum. If the upstream instance of Hydrant fails 2331while Bobbin is live, its list/count endpoints stop 2332advancing and report a stale cursor. Single-lookups 2333will continue working, due to the second dependency: 2334[Slingshot](https://tangled.org/did:plc:c7mc2fn47ihdihul4vjwsuy3/tree/main/slingshot). 2335Slingshot fetches individual records & resolves identities. 2336If the upstream instance of Slingshot fails, single-lookups 2337will fail with a `502` error. There are some aggregation 2338endpoints that use Slingshot for hydrating, which will also 2339fail. 2340 2341A soft dependency that ought to exist for Bobbin to operate 2342correctly is simply the plethora of knots that are out 2343there, that Bobbin talks to directly for git data and, for 2344knots at v1.15+, members & collaborators. 2345 2346## Building Bobbin 2347 2348Bobbin is under [Tangled's core monorepo, under bobbin/](https://tangled.org/did:plc:j5hmlfdrwkvtxm7cjmu7j2is/tree/master/bobbin). 2349Here's an easy local debug-build: 2350 2351```sh 2352cargo build -p bobbin 2353``` 2354 2355Bobbin loves being in a container. When using 2356`bobbin/containerfiles/bobbin.Containerfile`, it runs `cargo 2357build --release --bin bobbin --package bobbin` within a 2358little Debian runtime, exposing port 8090. 2359 2360## Configuration 2361 2362The best way to configure Bobbin is via a toml config file. 2363There's an `example.toml` in [Bobbin's subdir](https://tangled.org/did:plc:j5hmlfdrwkvtxm7cjmu7j2is/blob/master/bobbin/example.toml). 2364Every value is overridable by a `BOBBIN_*` env var. 2365The load order is env, then `--config <path>`, then 2366`/etc/bobbin/config.toml`, then built-in defaults. 2367 2368Load and check a config without starting the server: 2369 2370```sh 2371bobbin --config config.toml validate 2372``` 2373 2374Minimal config is the two upstream URLs. The hydrant URL 2375takes `ws://` or `wss://`. An `http://` or `https://` 2376URL is rewritten to the matching websocket scheme at 2377connection-time. 2378 2379```toml 2380[server] 2381binds = ["127.0.0.1:8090"] 2382 2383# Loopback-only & can leave empty to disable debug introspection. 2384debug_bind = "127.0.0.1:8091" 2385 2386[hydrant] 2387url = "https://hydrant.example.com" 2388 2389[slingshot] 2390url = "https://slingshot.example.com" 2391``` 2392 2393> 🦪 Lewis 2394> 2395> At time of writing, we (Tangled) don't host public 2396> instances of Hydrant or Slingshot. You will have to 2397> find public instances or spin these up yourself! :P 2398 2399Take a gander in the project's example.toml for an 2400exhaustive list of things to configure. 2401 2402You will discover fun things such as a configurable adaptive 2403loop that watches the cgroup memory limit & throttles heavy 2404requests under pressure. It only works if it detects a 2405cgroup limit is present. The config for that is in the 2406`[backpressure]` block of the config template. 2407 2408## Running Bobbin 2409 2410Start the server using a config toml: 2411 2412```bash 2413bobbin --config config.toml 2414``` 2415Bobbin wakes up in a cold sweat and immediately gets to 2416work: 24171. It binds its listeners, connects to the Hydrant stream 2418 in the background. 24192. It serves requests from the first 2420 moment it's alive, even before the Hydrant stream connects 2421 or finishes catching up. Having a cold Hydrant itself 2422 costs only latency and approximate counts. 2423 2424## The API 2425 2426**Single lookups** take a record's AT-URI. 2427 2428- `getRepo` takes the repo URI: 2429 2430```sh 2431curl "$BOBBIN/xrpc/sh.tangled.repo.getRepo?repo=at://did:plc:boltless/sh.tangled.repo/squid" 2432``` 2433```json 2434{ 2435 "uri": "at://did:plc:boltless/sh.tangled.repo/squid", 2436 "cid": "bafyrei...", 2437 "value": { "$type": "sh.tangled.repo", "knot": "knot1.tangled.sh", "description": "...", "createdAt": "..." } 2438} 2439``` 2440 2441- `getProfile` takes the full profile record URI, so a bare 2442 handle or DID will not resolve: 2443 2444```sh 2445curl "$BOBBIN/xrpc/sh.tangled.actor.getProfile?actor=at://did:plc:boltless/sh.tangled.actor.profile/self" 2446``` 2447 2448- If Slingshot cannot serve the record, the response is `502`: 2449 2450```json 2451{ "error": "UpstreamFailed", "message": "upstream unavailable: ..." } 2452``` 2453 2454**Aggregation** endpoints come in `list*` and `count*` pairs, 2455each with a `*By` sibling, and require a `subject` query param. 2456 2457- `listRepos` and `countRepos` key on the owner DID: 2458 2459```sh 2460curl "$BOBBIN/xrpc/sh.tangled.repo.countRepos?subject=did:plc:boltless" 2461``` 2462```json 2463{ "count": 7, "distinctAuthors": 1 } 2464``` 2465 2466```sh 2467curl "$BOBBIN/xrpc/sh.tangled.repo.listRepos?subject=did:plc:boltless&limit=3" 2468``` 2469```json 2470{ "items": [ { "uri": "at://did:plc:boltless/sh.tangled.repo/squid", "cid": "bafyrei...", "value": { } } ], "cursor": null } 2471``` 2472 2473- Bobbin validates the subject per collection. Here a repo URI 2474 is passed where a bare DID is required, so the call returns a 2475 `400`: 2476 2477```sh 2478curl "$BOBBIN/xrpc/sh.tangled.graph.listFollows?subject=at://did:plc:boltless/sh.tangled.repo/squid" 2479``` 2480```json 2481{ "error": "InvalidRequest", "message": "invalid request: subject must be a bare did, got at-uri with collection sh.tangled.repo" } 2482``` 2483 2484**Search** is a single endpoint over an in-mem full-text 2485index: 2486 2487```sh 2488curl "$BOBBIN/xrpc/sh.tangled.search.query?q=tangled&limit=2" 2489``` 2490```json 2491{ "hits": [ { "uri": "at://...", "cid": "...", "nsid": "sh.tangled.repo", "score": 27.1, "value": { } } ], "cursor": null } 2492``` 2493 2494**Git data** such as blob, tree, diff, log, and archive proxies 2495straight to the repo's knot, streamed back without caching. 2496 2497## Coverage and warm-up 2498 2499- While the edge index is catching up from Hydrant, 2500 the aggregation count is a lower bound & may still climb. 2501- One endpoint reports how far along the backfill it is: 2502 2503```sh 2504curl "$BOBBIN/xrpc/sh.tangled.bobbin.getCoverage" 2505``` 2506 2507While warming up: 2508 2509```json 2510{ "ready": false, "eventsProcessed": 45588, "lastCursor": 51658 } 2511``` 2512 2513Once caught up, Bobbin flips to ready: 2514 2515```json 2516{ "ready": true, "eventsProcessed": 106085, "lastCursor": 116527 } 2517``` 2518 2519If starting up Hydrant for the first time, Hydrant itself 2520will take a decent while (a couple of hours) to backfill 2521from PDSes. Hydrant stores its backfill on disk. Bobbin 2522restart reaches `ready` in minutes by replaying event from 2523an already-populated Hydrant. If your Hydrant is new, expect 2524Bobbin to backfill in that same couple of hours that Hydrant 2525takes. 2526 2527## Loose ends and not-gonna-impl 2528 2529- **No coverage signal for per-knot rosters yet.** 2530 Coverage tracks the hydrant stream only. A v1.15 knot 2531 that is unreachable serves a stale or empty member set 2532 with nothing to flag it. 2533- **Knot eventstream fan-out isn't pooled.** 2534 Bobbin opens one websocket per v1.15 2535 knot on top of the hydrant subscription. A network with 2536 thousands of knots wants pooling or a shared subscription. 2537- **No sequential issue or PR numbers.** bobbin returns rkeys, 2538 not `#42` style ids like the web appview. A client 2539 deriving a display number does it from creation order. But 2540 why bother? rkeys are the IDs. 2541 2542# Hacking on Tangled 2543 2544We highly recommend [installing 2545Nix](https://nixos.org/download/) (the package manager) 2546before working on the codebase. The Nix flake provides a lot 2547of helpers to get started and most importantly, builds and 2548dev shells are entirely deterministic. 2549 2550To set up your dev environment: 2551 2552```bash 2553nix develop 2554``` 2555 2556Non-Nix users can look at the `devShell` attribute in the 2557`flake.nix` file to determine necessary dependencies. 2558 2559## Running the appview 2560 2561The appview requires Redis and OAuth JWKs. Start these 2562first, before launching the appview itself. 2563 2564```bash 2565# OAuth JWKs should already be set up by the Nix devshell: 2566echo $TANGLED_OAUTH_CLIENT_SECRET 2567z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc 2568 2569echo $TANGLED_OAUTH_CLIENT_KID 25701761667908 2571 2572# if not, you can set it up yourself: 2573goat key generate -t P-256 2574Key Type: P-256 / secp256r1 / ES256 private key 2575Secret Key (Multibase Syntax): save this securely (eg, add to password manager) 2576 z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL 2577Public Key (DID Key Syntax): share or publish this (eg, in DID document) 2578 did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR 2579 2580# the secret key from above 2581export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..." 2582 2583# Run Redis in a new shell to store OAuth sessions 2584redis-server 2585``` 2586 2587The Nix flake exposes a few `app` attributes (run `nix 2588flake show` to see a full list of what the flake provides), 2589one of the apps runs the appview with the `air` 2590live-reloader: 2591 2592```bash 2593TANGLED_DEV=true nix run .#watch-appview 2594 2595# TANGLED_DB_PATH might be of interest to point to 2596# different sqlite DBs 2597 2598# in a separate shell, you can live-reload tailwind 2599nix run .#watch-tailwind 2600``` 2601 2602## Running knots and spindles 2603 2604An end-to-end knot setup requires setting up a machine with 2605`sshd`, `AuthorizedKeysCommand`, and a Git user, which is 2606quite cumbersome. So the Nix flake provides a 2607`nixosConfiguration` to do so. 2608 2609<details> 2610 <summary><strong>macOS users will have to set up a Nix Builder first</strong></summary> 2611 2612In order to build Tangled's dev VM on macOS, you will 2613first need to set up a Linux Nix builder. The recommended 2614way to do so is to run a [`darwin.linux-builder` 2615VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder) 2616and to register it in `nix.conf` as a builder for Linux 2617with the same architecture as your Mac (`linux-aarch64` if 2618you are using Apple Silicon). 2619 2620If you're on nix-darwin, you can simply add 2621 2622``` 2623nix.linux-builder.enable = true; 2624``` 2625 2626to your host's `configuration.nix`. 2627 2628Alternatively, you can use any other method to set up a 2629Linux machine with Nix installed that you can `sudo ssh` 2630into (in other words, root user on your Mac has to be able 2631to ssh into the Linux machine without entering a password) 2632and that has the same architecture as your Mac. See 2633[remote builder 2634instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements) 2635for how to register such a builder in `nix.conf`. 2636 2637> WARNING: If you'd like to use 2638> [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or 2639> [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo 2640ssh` works can be tricky. It seems to be [possible with 2641> Orbstack](https://github.com/orgs/orbstack/discussions/1669). 2642 2643</details> 2644 2645To begin, grab your DID from http://localhost:3000/settings. 2646Then, set `TANGLED_VM_KNOT_OWNER` and 2647`TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a 2648lightweight NixOS VM like so: 2649 2650```bash 2651nix run --impure .#vm 2652 2653# type `poweroff` at the shell to exit the VM 2654``` 2655 2656This starts a knot on port 6444, a spindle on port 6555 2657with `ssh` exposed on port 2222. 2658 2659Once the services are running, head to 2660http://localhost:3000/settings/knots and hit "Verify". It should 2661verify the ownership of the services instantly if everything 2662went smoothly. 2663 2664You can push repositories to this VM with this ssh config 2665block on your main machine: 2666 2667```bash 2668Host nixos-shell 2669 Hostname localhost 2670 Port 2222 2671 User git 2672 IdentityFile ~/.ssh/my_tangled_key 2673``` 2674 2675Set up a remote called `local-dev` on a git repo: 2676 2677```bash 2678git remote add local-dev git@nixos-shell:user/repo 2679git push local-dev main 2680``` 2681 2682The above VM should already be running a spindle on 2683`localhost:6555`. Head to http://localhost:3000/settings/spindles and 2684hit "Verify". You can then configure each repository to use 2685this spindle and run CI jobs. 2686 2687Of interest when debugging spindles: 2688 2689``` 2690# Service logs from journald: 2691journalctl -xeu spindle 2692 2693# CI job logs from disk: 2694ls /var/log/spindle 2695 2696# Debugging spindle database: 2697sqlite3 /var/lib/spindle/spindle.db 2698 2699# litecli has a nicer REPL interface: 2700litecli /var/lib/spindle/spindle.db 2701``` 2702 2703If for any reason you wish to disable either one of the 2704services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 2705`services.tangled.spindle.enable` (or 2706`services.tangled.knot.enable`) to `false`. 2707 2708# Contribution guide 2709 2710## Commit guidelines 2711 2712We follow a commit style similar to the Go project. Please keep commits: 2713 2714- **atomic**: each commit should represent one logical change 2715- **descriptive**: the commit message should clearly describe what the 2716 change does and why it's needed 2717 2718### Message format 2719 2720``` 2721<service/top-level directory>/<affected package/directory>: <short summary of change> 2722 2723Optional longer description can go here, if necessary. Explain what the 2724change does and why, especially if not obvious. Reference relevant 2725issues or PRs when applicable. These can be links for now since we don't 2726auto-link issues/PRs yet. 2727``` 2728 2729Here are some examples: 2730 2731``` 2732appview/state: fix token expiry check in middleware 2733 2734The previous check did not account for clock drift, leading to premature 2735token invalidation. 2736``` 2737 2738``` 2739knotserver/git/service: improve error checking in upload-pack 2740``` 2741 2742### General notes 2743 2744- PRs get merged "as-is" (fast-forward)—like applying a patch-series 2745 using `git am`. At present, there is no squashing—so please author 2746 your commits as they would appear on `master`, following the above 2747 guidelines. 2748- If there is a lot of nesting, for example "appview: 2749 pages/templates/repo/fragments: ...", these can be truncated down to 2750 just "appview: repo/fragments: ...". If the change affects a lot of 2751 subdirectories, you may abbreviate to just the top-level names, e.g. 2752 "appview: ..." or "knotserver: ...". 2753- Keep commits lowercased with no trailing period. 2754- Use the imperative mood in the summary line (e.g., "fix bug" not 2755 "fixed bug" or "fixes bug"). 2756- Try to keep the summary line under 72 characters, but we aren't too 2757 fussed about this. 2758- Follow the same formatting for PR titles if filled manually. 2759- Don't include unrelated changes in the same commit. 2760- Avoid noisy commit messages like "wip" or "final fix"—rewrite history 2761 before submitting if necessary. 2762 2763## Code formatting 2764 2765We use a variety of tools to format our code, and multiplex them with 2766[`treefmt`](https://treefmt.com). All you need to do to format your changes 2767is run `nix run .#fmt` (or just `treefmt` if you're in the devshell). 2768 2769## Proposals for bigger changes 2770 2771Small fixes like typos, minor bugs, or trivial refactors can be 2772submitted directly as PRs. 2773 2774For larger changes—especially those introducing new features, significant 2775refactoring, or altering system behavior—please open a proposal first. This 2776helps us evaluate the scope, design, and potential impact before implementation. 2777 2778Create a new issue titled: 2779 2780``` 2781proposal: <affected scope>: <summary of change> 2782``` 2783 2784In the description, explain: 2785 2786- What the change is 2787- Why it's needed 2788- How you plan to implement it (roughly) 2789- Any open questions or tradeoffs 2790 2791We'll use the issue thread to discuss and refine the idea before moving 2792forward. 2793 2794## Developer Certificate of Origin (DCO) 2795 2796We require all contributors to certify that they have the right to 2797submit the code they're contributing. To do this, we follow the 2798[Developer Certificate of Origin 2799(DCO)](https://developercertificate.org/). 2800 2801By signing your commits, you're stating that the contribution is your 2802own work, or that you have the right to submit it under the project's 2803license. This helps us keep things clean and legally sound. 2804 2805To sign your commit, just add the `-s` flag when committing: 2806 2807```sh 2808git commit -s -m "your commit message" 2809``` 2810 2811This appends a line like: 2812 2813``` 2814Signed-off-by: Your Name <your.email@example.com> 2815``` 2816 2817We won't merge commits if they aren't signed off. If you forget, you can 2818amend the last commit like this: 2819 2820```sh 2821git commit --amend -s 2822``` 2823 2824If you're submitting a PR with multiple commits, make sure each one is 2825signed. 2826 2827For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command 2828to make it sign off commits in the tangled repo: 2829 2830```shell 2831# Safety check, should say "No matching config key..." 2832jj config list templates.commit_trailers 2833# The command below may need to be adjusted if the command above returned something. 2834jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)" 2835``` 2836 2837Refer to the [jujutsu 2838documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers) 2839for more information. 2840 2841# Troubleshooting guide 2842 2843## Login issues 2844 2845Owing to the distributed nature of OAuth on AT Protocol, you 2846may run into issues with logging in. If you run a 2847self-hosted PDS: 2848 2849- You may need to ensure that your PDS is timesynced using 2850 NTP: 2851 - Enable the `ntpd` service 2852 - Run `ntpd -qg` to synchronize your clock 2853- You may need to increase the default request timeout: 2854 `NODE_OPTIONS="--network-family-autoselection-attempt-timeout=500"` 2855 2856## Empty punchcard 2857 2858For Tangled to register commits that you make across the 2859network, you need to setup one of following: 2860 2861- The committer email should be a verified email associated 2862 to your account. You can add and verify emails on the 2863 settings page. 2864- Or, the committer email should be set to your account's 2865 DID: `git config user.email "did:plc:foobar"`. You can find 2866 your account's DID on the settings page 2867 2868## Commit is not marked as verified 2869 2870Tangled only supports SSH commit signatures. Ensure the SSH public key you use 2871for signing is uploaded to Tangled. 2872 2873To sign commits using an SSH key with git: 2874 2875``` 2876git config --global gpg.format ssh 2877git config --global user.signingkey ~/.ssh/tangled-key 2878``` 2879 2880To sign commits using an SSH key with jj, add this to your 2881config: 2882 2883``` 2884[signing] 2885behavior = "own" 2886backend = "ssh" 2887key = "~/.ssh/tangled-key" 2888``` 2889 2890## Self-hosted knot issues 2891 2892If you need help troubleshooting a self-hosted knot, check 2893out the [knot troubleshooting 2894guide](/knot-self-hosting-guide.html#troubleshooting).