···55to Tangled using standard ATProto records so they show up natively
66in Tangled's UI.
7788-## What it does
88+## Example
99+1010+A Tangled workflow that fires a Buildkite pipeline on every push to
1111+`main` and every pull request targeting `main`:
1212+1313+```yaml
1414+# .tangled/workflows/ci.yml
1515+when:
1616+ - event: ["push"]
1717+ branch: ["main"]
1818+ - event: ["pull_request"]
1919+ branch: ["main"]
2020+2121+tack:
2222+ buildkite:
2323+ pipeline: my-app-ci
2424+```
2525+2626+The `when:` block is the standard Tangled trigger schema; the
2727+`tack:` block tells tack which Buildkite pipeline to fire and how.
2828+See [docs/buildkite.md](docs/buildkite.md) for the full set of
2929+options and end-to-end Buildkite setup. Tack can support multiple
3030+providers.
3131+3232+## How it Works
9331034Tack is a drop-in alternative to the stock `spindle` runner. You run
1135`tack` and [register it using the standard UI](https://tangled.org/settings/spindles).
···6084that's useful for exercising the jetstream → knot → `/events` flow
6185locally without a real CI account.
62866363-## Buildkite
6464-6565-[Buildkite](https://buildkite.com) is the primary provider tack
6666-supports today. In Buildkite mode, every Tangled pipeline trigger
6767-fans out into one Buildkite build per workflow on the pipeline that
6868-workflow names; build state flows back to Tangled via Buildkite's
6969-notification webhooks.
7070-7171-### How it fits together
8787+## Providers
72887373-```
7474- sh.tangled.pipeline Buildkite
7575- trigger record ──▶ tack ──▶ Create Build ─┐
7676- │
7777- /webhooks/buildkite ◀──── notification ◀─────┘
7878- │
7979- ▼
8080- sh.tangled.pipeline.status (broadcast on /events)
8181-```
8989+Provider-specific setup (Buildkite-side configuration, the
9090+provider's tack env vars, and the workflow YAML schema) lives in
9191+its own doc per provider:
82928383-* **Spawn:** for each workflow on a pipeline trigger, tack POSTs to
8484- `/v2/organizations/<org>/pipelines/<slug>/builds`. Both `<org>` and
8585- `<slug>` come from the workflow's YAML body (see
8686- [Configuring your workflows](#configuring-your-workflows)).
8787-* **Track:** tack persists the resulting `(build_uuid → knot, rkey,
8888- workflow)` mapping in its local SQLite store so it can later
8989- resolve incoming webhooks back to the originating Tangled
9090- pipeline.
9191-* **Report:** Buildkite delivers `build.*` events to
9292- `POST /webhooks/buildkite`. tack authenticates each request,
9393- translates the Buildkite state into a Tangled status, and
9494- broadcasts a `sh.tangled.pipeline.status` record on `/events`.
9595-9696-### Setting up Buildkite
9797-9898-These steps happen once on the Buildkite side, before tack can talk
9999-to it.
100100-101101-#### 1. Create one or more pipelines
102102-103103-Each Tangled workflow targets exactly one Buildkite pipeline by
104104-slug. There's no requirement that pipelines map 1:1 to workflows —
105105-many users point every workflow at a single pipeline whose
106106-`pipeline.yml` does `pipeline upload some-file-${TACK_WORKFLOW}.yml`,
107107-keeping all the per-workflow logic in the repo rather than in
108108-Buildkite's UI.
109109-110110-In your Buildkite org, **Pipelines → New pipeline**:
111111-112112-* Repository: any URL (the agent only needs to be able to clone it).
113113-* Steps: a minimal `pipeline upload` is usually enough — tack passes
114114- the workflow name through `$TACK_WORKFLOW` so you can branch on
115115- it.
116116-117117-Note the pipeline slug from the URL
118118-(`https://buildkite.com/<org>/<pipeline-slug>`); your workflow YAML
119119-will reference it.
120120-121121-#### 2. Create an API access token
122122-123123-Tack uses a single API token to create builds, list jobs, and fetch
124124-logs. Generate one at
125125-<https://buildkite.com/user/api-access-tokens> with these scopes:
126126-127127-| Scope | Used for |
128128-| ------------------- | ------------------------------------------------- |
129129-| `read_organizations`| Sanity-checking the configured org slug |
130130-| `write_builds` | `POST .../builds` when a Tangled trigger arrives |
131131-| `read_builds` | Resolving build → jobs for the `/logs` endpoint |
132132-| `read_build_logs` | Streaming job logs back to the Tangled appview |
133133-134134-Restrict the token to the specific organization(s) tack will spawn
135135-into.
136136-137137-#### 3. Configure a notification webhook
138138-139139-Builds report their state back to tack through Buildkite's
140140-notification service.
141141-142142-In your Buildkite org, **Settings → Notification Services → Add →
143143-Webhook**:
144144-145145-* **Webhook URL:** `https://<your-tack-host>/webhooks/buildkite`
146146-* **Token / Secret:** any high-entropy string. You'll set the same
147147- value in `TACK_BUILDKITE_WEBHOOK_SECRET`.
148148-* **Events:** `build.scheduled`, `build.running`, `build.finished`
149149- (job-level events are ignored).
150150-* **Pipelines:** the pipelines tack will fire builds on.
151151-152152-Buildkite supports two header schemes for authenticating webhooks;
153153-tack supports both:
154154-155155-| Header scheme | `TACK_BUILDKITE_WEBHOOK_MODE` | Notes |
156156-| ----------------------- | ----------------------------- | -------------------------------------------- |
157157-| `X-Buildkite-Token` | `token` (default) | Secret is sent verbatim in the header |
158158-| `X-Buildkite-Signature` | `signature` | HMAC-SHA256 of `<timestamp>.<body>`; safer |
159159-160160-Pick `signature` if the notification setting offers it — it doesn't
161161-expose the secret on the wire.
162162-163163-### Configuring tack
164164-165165-Setting `TACK_BUILDKITE_TOKEN` is the master switch that puts tack
166166-into Buildkite mode. The other variables in this section are then
167167-required.
168168-169169-| Env var | Description |
170170-| ------------------------------- | ------------------------------------------------------------------------------ |
171171-| `TACK_BUILDKITE_TOKEN` | Buildkite API token (enables Buildkite mode) |
172172-| `TACK_BUILDKITE_ORG` | Default Buildkite organization slug (workflows may override via YAML) |
173173-| `TACK_BUILDKITE_WEBHOOK_SECRET` | Shared secret for `/webhooks/buildkite` auth |
174174-| `TACK_BUILDKITE_WEBHOOK_MODE` | `token` (default) or `signature` — must match the notification service |
175175-176176-The pipeline a workflow runs against is **not** an environment
177177-variable. It lives inside the workflow YAML so each repo can target
178178-its own pipeline without an operator round-trip.
179179-180180-### Configuring your workflows
181181-182182-A Tangled workflow's `raw` body is parsed by tack as YAML. Only
183183-`pipeline` is required — every other field is an optional override
184184-or extension of what the trigger metadata already provides:
185185-186186-```yaml
187187-# Required: which Buildkite pipeline this workflow fires.
188188-pipeline: my-pipeline-slug
189189-190190-# Optional: org override. Defaults to TACK_BUILDKITE_ORG. The API
191191-# token must have access to whichever org you target.
192192-org: another-org
193193-194194-# Optional: human-readable build message (default: "tangled: <name>").
195195-message: "Custom build message"
196196-197197-# Optional: pin the commit/branch tack would otherwise derive from
198198-# the trigger. Useful for manual triggers (which carry no commit).
199199-commit: abcdef0123
200200-branch: main
201201-202202-# Optional: extra env + meta_data merged on top of tack's defaults
203203-# (see "What tack injects into every build" below).
204204-env:
205205- CUSTOM_VAR: value
206206-meta_data:
207207- custom-key: value
208208-209209-# Optional: forwarded verbatim to the Buildkite create-build API.
210210-clean_checkout: true
211211-ignore_pipeline_branch_filters: true # default: true
212212-author:
213213- name: "Author Name"
214214- email: "author@example.com"
215215-```
216216-217217-When the trigger is a pull request, tack auto-populates Buildkite's
218218-`pull_request_base_branch` from the PR target so step-level branch
219219-filters work without extra config.
220220-221221-#### What tack injects into every build
222222-223223-Regardless of what the workflow YAML adds on top, tack always
224224-provides the following so your Buildkite pipeline can recover the
225225-Tangled identity of the build:
226226-227227-| Channel | Key | Value |
228228-| ----------- | -------------------- | ---------------------------------------- |
229229-| `env` | `TACK_KNOT` | knot hostname the pipeline came from |
230230-| `env` | `TACK_PIPELINE_RKEY` | rkey of the originating pipeline record |
231231-| `env` | `TACK_WORKFLOW` | workflow name (typically a YAML filename) |
232232-| `env` | `TACK_WORKFLOW_RAW` | the workflow's raw YAML body |
233233-| `meta_data` | `tack:knot` | same as `TACK_KNOT` |
234234-| `meta_data` | `tack:pipeline_rkey` | same as `TACK_PIPELINE_RKEY` |
235235-| `meta_data` | `tack:workflow` | same as `TACK_WORKFLOW` |
236236-237237-A common pattern is for the Buildkite pipeline's root step to do a
238238-`pipeline upload` against a workflow-specific YAML file based on
239239-`$TACK_WORKFLOW`, e.g.:
240240-241241-```yaml
242242-# Buildkite pipeline.yml
243243-steps:
244244- - label: ":pipeline: dispatch ${TACK_WORKFLOW}"
245245- command: "buildkite-agent pipeline upload .buildkite/${TACK_WORKFLOW}"
246246-```
9393+* [Buildkite](docs/buildkite.md)
+126
docs/buildkite.md
···11+# Buildkite
22+33+For [Buildkite](https://buildkite.com), every Tangled pipeline trigger
44+fans out into one Buildkite build per workflow on the pipeline that
55+workflow configures. Buildkite must be configured with a webhook
66+back to Tack to communicate status updates.
77+88+## Setting up Buildkite
99+1010+This must happen before configuring tack within Buildkite.
1111+1212+### 1. Create one or more pipelines
1313+1414+In your Buildkite org, **Pipelines → New pipeline**:
1515+1616+* Repository: any URL (the agent only needs to be able to clone it).
1717+* Steps: whatever you want.
1818+1919+Note the pipeline slug from the URL
2020+(`https://buildkite.com/<org>/<pipeline-slug>`); your workflow YAML
2121+will reference it.
2222+2323+### 2. Create an API access token
2424+2525+Tack uses a single API token to create builds, list jobs, and fetch
2626+logs. Generate one at
2727+<https://buildkite.com/user/api-access-tokens> with these scopes:
2828+2929+| Scope | Used for |
3030+| ------------------- | ------------------------------------------------- |
3131+| `read_organizations`| Sanity-checking the configured org slug |
3232+| `write_builds` | `POST .../builds` when a Tangled trigger arrives |
3333+| `read_builds` | Resolving build → jobs for the `/logs` endpoint |
3434+| `read_build_logs` | Streaming job logs back to the Tangled appview |
3535+3636+Restrict the token to the specific organization(s) tack will spawn
3737+into.
3838+3939+### 3. Configure a notification webhook
4040+4141+Builds report their state back to tack through Buildkite's
4242+notification service.
4343+4444+In your Buildkite org, **Settings → Notification Services → Add →
4545+Webhook**:
4646+4747+* **Webhook URL:** `https://<your-tack-host>/webhooks/buildkite`
4848+* **Token / Secret:** any high-entropy string. You'll set the same
4949+ value in `TACK_BUILDKITE_WEBHOOK_SECRET`.
5050+* **Events:** `build.scheduled`, `build.running`, `build.finished`
5151+ (job-level events are ignored).
5252+* **Pipelines:** the pipelines tack will fire builds on.
5353+5454+Buildkite supports two header schemes for authenticating webhooks and
5555+Tack supports both:
5656+5757+| Header scheme | `TACK_BUILDKITE_WEBHOOK_MODE` | Notes |
5858+| ----------------------- | ----------------------------- | -------------------------------------------- |
5959+| `X-Buildkite-Token` | `token` (default) | Secret is sent verbatim in the header |
6060+| `X-Buildkite-Signature` | `signature` | HMAC-SHA256 of `<timestamp>.<body>`; safer |
6161+6262+## Configure Tack
6363+6464+| Env var | Description |
6565+| ------------------------------- | ------------------------------------------------------------------------------ |
6666+| `TACK_BUILDKITE_TOKEN` | Buildkite API token (enables Buildkite mode) |
6767+| `TACK_BUILDKITE_ORG` | Default Buildkite organization slug (workflows may override via YAML) |
6868+| `TACK_BUILDKITE_WEBHOOK_SECRET` | Shared secret for `/webhooks/buildkite` auth |
6969+| `TACK_BUILDKITE_WEBHOOK_MODE` | `token` (default) or `signature` — must match the notification service |
7070+7171+The pipeline a workflow runs against is **not** an environment
7272+variable. It lives inside the workflow YAML so each repo can target
7373+its own pipeline without an operator round-trip.
7474+7575+## Configuring your Tangled workflows
7676+7777+Tack's configuration lives under a `tack:` namespace so the workflow body
7878+can grow other top-level keys without colliding.
7979+8080+Only `pipeline` is required:
8181+8282+```yaml
8383+tack:
8484+ buildkite:
8585+ # Required: which Buildkite pipeline this workflow fires.
8686+ pipeline: my-pipeline-slug
8787+8888+ # Optional: org override. Defaults to TACK_BUILDKITE_ORG. The
8989+ # API token must have access to whichever org you target.
9090+ org: another-org
9191+9292+ # Optional: forwarded verbatim to the Buildkite create-build
9393+ # API. Omit to use Buildkite's default (false).
9494+ clean_checkout: true
9595+```
9696+9797+When the trigger is a pull request, tack auto-populates Buildkite's
9898+`pull_request_base_branch` from the PR target so step-level branch
9999+filters work without extra config.
100100+101101+### What tack injects into every build
102102+103103+Regardless of what the workflow YAML adds on top, tack always
104104+provides the following so your Buildkite pipeline can recover the
105105+Tangled identity of the build:
106106+107107+| Channel | Key | Value |
108108+| ----------- | -------------------- | ---------------------------------------- |
109109+| `env` | `TACK_KNOT` | knot hostname the pipeline came from |
110110+| `env` | `TACK_PIPELINE_RKEY` | rkey of the originating pipeline record |
111111+| `env` | `TACK_WORKFLOW` | workflow name (typically a YAML filename) |
112112+| `env` | `TACK_WORKFLOW_RAW` | the workflow's raw YAML body |
113113+| `meta_data` | `tack:knot` | same as `TACK_KNOT` |
114114+| `meta_data` | `tack:pipeline_rkey` | same as `TACK_PIPELINE_RKEY` |
115115+| `meta_data` | `tack:workflow` | same as `TACK_WORKFLOW` |
116116+117117+A common pattern is for the Buildkite pipeline's root step to do a
118118+`pipeline upload` against a workflow-specific YAML file based on
119119+`$TACK_WORKFLOW`, e.g.:
120120+121121+```yaml
122122+# Buildkite pipeline.yml
123123+steps:
124124+ - label: ":pipeline: dispatch ${TACK_WORKFLOW}"
125125+ command: "buildkite-agent pipeline upload .buildkite/${TACK_WORKFLOW}"
126126+```
+18-36
provider_buildkite_test.go
···155155}
156156157157// TestBuildkiteSpawnWorkflowConfig pins the YAML → create-build
158158-// translation: pipeline + org from YAML pick the request URL,
159159-// message/env/meta_data come through, and the trigger's PR target
160160-// branch lands as `pull_request_base_branch`. Together these cover
161161-// the "smuggle Buildkite parameters through workflow YAML" path.
158158+// translation: `tack.buildkite.{pipeline,org}` pick the request URL,
159159+// `clean_checkout` flows through, and the trigger's PR target
160160+// branch lands as `pull_request_base_branch` automatically.
162161func TestBuildkiteSpawnWorkflowConfig(t *testing.T) {
163162 type captured struct {
164163 path string
···175174 p, _, _, _ := newBuildkiteTestProvider(t, buildkite.WebhookModeToken, "s", bk)
176175177176 raw := strings.Join([]string{
178178- "pipeline: workflow-pipe",
179179- "org: workflow-org",
180180- "message: smuggled message",
181181- "env:",
182182- " CUSTOM: value",
183183- "meta_data:",
184184- " custom: meta",
185185- "clean_checkout: true",
186186- "author:",
187187- " name: Author",
188188- " email: a@example.com",
177177+ "tack:",
178178+ " buildkite:",
179179+ " pipeline: workflow-pipe",
180180+ " org: workflow-org",
181181+ " clean_checkout: true",
189182 }, "\n") + "\n"
190183191184 trigger := &tangled.Pipeline_TriggerMetadata{
···208201 if got.body.Commit != "deadbeef" || got.body.Branch != "feature" {
209202 t.Fatalf("commit/branch = %q/%q", got.body.Commit, got.body.Branch)
210203 }
211211- if got.body.Message != "smuggled message" {
212212- t.Fatalf("message = %q", got.body.Message)
213213- }
214214- if got.body.Env["CUSTOM"] != "value" {
215215- t.Fatalf("env[CUSTOM] missing: %+v", got.body.Env)
216216- }
217217- // tack defaults must still be present (user keys merge,
218218- // don't replace).
204204+ // tack-managed env/meta still present.
219205 if got.body.Env["TACK_WORKFLOW"] != "ci.yml" {
220206 t.Fatalf("env[TACK_WORKFLOW] missing: %+v", got.body.Env)
221207 }
222222- if got.body.MetaData["custom"] != "meta" ||
223223- got.body.MetaData[bkMetaWorkflow] != "ci.yml" {
224224- t.Fatalf("meta_data wrong: %+v", got.body.MetaData)
208208+ if got.body.MetaData[bkMetaWorkflow] != "ci.yml" {
209209+ t.Fatalf("meta_data missing identity tuple: %+v", got.body.MetaData)
225210 }
226211 if !got.body.CleanCheckout {
227212 t.Fatalf("clean_checkout not set")
228213 }
229229- // IgnorePipelineBranchFilters defaults to true (see
230230- // workflowConfig comment).
214214+ // IgnorePipelineBranchFilters is hard-coded true; see
215215+ // buildCreateRequest comment.
231216 if !got.body.IgnorePipelineBranchFilters {
232232- t.Fatalf("ignore_pipeline_branch_filters not defaulted to true")
217217+ t.Fatalf("ignore_pipeline_branch_filters not on")
233218 }
234219 if got.body.PullRequestBaseBranch != "main" {
235220 t.Fatalf("pr base branch = %q; want main",
236221 got.body.PullRequestBaseBranch)
237222 }
238238- if got.body.Author == nil || got.body.Author.Email != "a@example.com" {
239239- t.Fatalf("author = %+v", got.body.Author)
240240- }
241223 case <-time.After(2 * time.Second):
242224 t.Fatal("CreateBuild not called")
243225 }
244226}
245227246228// TestBuildkiteSpawnInvalidYAML proves a workflow without the
247247-// required `pipeline` field is skipped — no API call, no DB row, no
248248-// status. A misconfigured workflow shouldn't be silently swept onto
249249-// some default pipeline.
229229+// required `tack.buildkite.pipeline` field is skipped — no API
230230+// call, no DB row, no status. A misconfigured workflow shouldn't
231231+// be silently swept onto some default pipeline.
250232func TestBuildkiteSpawnInvalidYAML(t *testing.T) {
251233 called := false
252234 bk := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
···265247266248 time.Sleep(50 * time.Millisecond)
267249 if called {
268268- t.Fatal("CreateBuild called for workflow missing pipeline")
250250+ t.Fatal("CreateBuild called for workflow missing tack.buildkite.pipeline")
269251 }
270252 rows, _ := st.EventsAfter(context.Background(), 0)
271253 if len(rows) != 0 {