···11+{{ define "title" }} privacy policy {{ end }}
22+{{ define "content" }}
33+<div class="max-w-4xl mx-auto px-4 py-8">
44+ <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
55+ <div class="prose prose-gray dark:prose-invert max-w-none">
66+ <h1>Privacy Policy</h1>
77+88+ <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p>
99+1010+ <p>This Privacy Policy describes how Tangled ("we," "us," or "our") collects, uses, and shares your personal information when you use our platform and services (the "Service").</p>
1111+1212+ <h2>1. Information We Collect</h2>
1313+1414+ <h3>Account Information</h3>
1515+ <p>When you create an account, we collect:</p>
1616+ <ul>
1717+ <li>Your chosen username</li>
1818+ <li>Email address</li>
1919+ <li>Profile information you choose to provide</li>
2020+ <li>Authentication data</li>
2121+ </ul>
2222+2323+ <h3>Content and Activity</h3>
2424+ <p>We store:</p>
2525+ <ul>
2626+ <li>Code repositories and associated metadata</li>
2727+ <li>Issues, pull requests, and comments</li>
2828+ <li>Activity logs and usage patterns</li>
2929+ <li>Public keys for authentication</li>
3030+ </ul>
3131+3232+ <h2>2. Data Location and Hosting</h2>
3333+ <div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 my-6">
3434+ <h3 class="text-blue-800 dark:text-blue-200 font-semibold mb-2">EU Data Hosting</h3>
3535+ <p class="text-blue-700 dark:text-blue-300">
3636+ <strong>All Tangled service data is hosted within the European Union.</strong> Specifically:
3737+ </p>
3838+ <ul class="text-blue-700 dark:text-blue-300 mt-2">
3939+ <li><strong>Personal Data Servers (PDS):</strong> Accounts hosted on Tangled PDS (*.tngl.sh) are located in Finland</li>
4040+ <li><strong>Application Data:</strong> All other service data is stored on EU-based servers</li>
4141+ <li><strong>Data Processing:</strong> All data processing occurs within EU jurisdiction</li>
4242+ </ul>
4343+ </div>
4444+4545+ <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 my-6">
4646+ <h3 class="text-yellow-800 dark:text-yellow-200 font-semibold mb-2">External PDS Notice</h3>
4747+ <p class="text-yellow-700 dark:text-yellow-300">
4848+ <strong>Important:</strong> If your account is hosted on Bluesky's PDS or other self-hosted Personal Data Servers (not *.tngl.sh), we do not control that data. The data protection, storage location, and privacy practices for such accounts are governed by the respective PDS provider's policies, not this Privacy Policy. We only control data processing within our own services and infrastructure.
4949+ </p>
5050+ </div>
5151+5252+ <h2>3. Third-Party Data Processors</h2>
5353+ <p>We only share your data with the following third-party processors:</p>
5454+5555+ <h3>Resend (Email Services)</h3>
5656+ <ul>
5757+ <li><strong>Purpose:</strong> Sending transactional emails (account verification, notifications)</li>
5858+ <li><strong>Data Shared:</strong> Email address and necessary message content</li>
5959+ <li><strong>Location:</strong> EU-compliant email delivery service</li>
6060+ </ul>
6161+6262+ <h3>Cloudflare (Image Caching)</h3>
6363+ <ul>
6464+ <li><strong>Purpose:</strong> Caching and optimizing image delivery</li>
6565+ <li><strong>Data Shared:</strong> Public images and associated metadata for caching purposes</li>
6666+ <li><strong>Location:</strong> Global CDN with EU data protection compliance</li>
6767+ </ul>
6868+6969+ <h2>4. How We Use Your Information</h2>
7070+ <p>We use your information to:</p>
7171+ <ul>
7272+ <li>Provide and maintain the Service</li>
7373+ <li>Process your transactions and requests</li>
7474+ <li>Send you technical notices and support messages</li>
7575+ <li>Improve and develop new features</li>
7676+ <li>Ensure security and prevent fraud</li>
7777+ <li>Comply with legal obligations</li>
7878+ </ul>
7979+8080+ <h2>5. Data Sharing and Disclosure</h2>
8181+ <p>We do not sell, trade, or rent your personal information. We may share your information only in the following circumstances:</p>
8282+ <ul>
8383+ <li>With the third-party processors listed above</li>
8484+ <li>When required by law or legal process</li>
8585+ <li>To protect our rights, property, or safety, or that of our users</li>
8686+ <li>In connection with a merger, acquisition, or sale of assets (with appropriate protections)</li>
8787+ </ul>
8888+8989+ <h2>6. Data Security</h2>
9090+ <p>We implement appropriate technical and organizational measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction. However, no method of transmission over the Internet is 100% secure.</p>
9191+9292+ <h2>7. Data Retention</h2>
9393+ <p>We retain your personal information for as long as necessary to provide the Service and fulfill the purposes outlined in this Privacy Policy, unless a longer retention period is required by law.</p>
9494+9595+ <h2>8. Your Rights</h2>
9696+ <p>Under applicable data protection laws, you have the right to:</p>
9797+ <ul>
9898+ <li>Access your personal information</li>
9999+ <li>Correct inaccurate information</li>
100100+ <li>Request deletion of your information</li>
101101+ <li>Object to processing of your information</li>
102102+ <li>Data portability</li>
103103+ <li>Withdraw consent (where applicable)</li>
104104+ </ul>
105105+106106+ <h2>9. Cookies and Tracking</h2>
107107+ <p>We use cookies and similar technologies to:</p>
108108+ <ul>
109109+ <li>Maintain your login session</li>
110110+ <li>Remember your preferences</li>
111111+ <li>Analyze usage patterns to improve the Service</li>
112112+ </ul>
113113+ <p>You can control cookie settings through your browser preferences.</p>
114114+115115+ <h2>10. Children's Privacy</h2>
116116+ <p>The Service is not intended for children under 16 years of age. We do not knowingly collect personal information from children under 16. If we become aware that we have collected such information, we will take steps to delete it.</p>
117117+118118+ <h2>11. International Data Transfers</h2>
119119+ <p>While all our primary data processing occurs within the EU, some of our third-party processors may process data outside the EU. When this occurs, we ensure appropriate safeguards are in place, such as Standard Contractual Clauses or adequacy decisions.</p>
120120+121121+ <h2>12. Changes to This Privacy Policy</h2>
122122+ <p>We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date.</p>
123123+124124+ <h2>13. Contact Information</h2>
125125+ <p>If you have any questions about this Privacy Policy or wish to exercise your rights, please contact us through our platform or via email.</p>
126126+127127+ <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400">
128128+ <p>This Privacy Policy complies with the EU General Data Protection Regulation (GDPR) and other applicable data protection laws.</p>
129129+ </div>
130130+ </div>
131131+ </div>
132132+</div>
133133+{{ end }}
+71
appview/pages/templates/legal/terms.html
···11+{{ define "title" }}terms of service{{ end }}
22+33+{{ define "content" }}
44+<div class="max-w-4xl mx-auto px-4 py-8">
55+ <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
66+ <div class="prose prose-gray dark:prose-invert max-w-none">
77+ <h1>Terms of Service</h1>
88+99+ <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p>
1010+1111+ <p>Welcome to Tangled. These Terms of Service ("Terms") govern your access to and use of the Tangled platform and services (the "Service") operated by us ("Tangled," "we," "us," or "our").</p>
1212+1313+ <h2>1. Acceptance of Terms</h2>
1414+ <p>By accessing or using our Service, you agree to be bound by these Terms. If you disagree with any part of these terms, then you may not access the Service.</p>
1515+1616+ <h2>2. Account Registration</h2>
1717+ <p>To use certain features of the Service, you must register for an account. You agree to provide accurate, current, and complete information during the registration process and to update such information to keep it accurate, current, and complete.</p>
1818+1919+ <h2>3. Account Termination</h2>
2020+ <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 my-6">
2121+ <h3 class="text-red-800 dark:text-red-200 font-semibold mb-2">Important Notice</h3>
2222+ <p class="text-red-700 dark:text-red-300">
2323+ <strong>We reserve the right to terminate, suspend, or restrict access to your account at any time, for any reason, or for no reason at all, at our sole discretion.</strong> This includes, but is not limited to, termination for violation of these Terms, inappropriate conduct, spam, abuse, or any other behavior we deem harmful to the Service or other users.
2424+ </p>
2525+ <p class="text-red-700 dark:text-red-300 mt-2">
2626+ Account termination may result in the loss of access to your repositories, data, and other content associated with your account. We are not obligated to provide advance notice of termination, though we may do so in our discretion.
2727+ </p>
2828+ </div>
2929+3030+ <h2>4. Acceptable Use</h2>
3131+ <p>You agree not to use the Service to:</p>
3232+ <ul>
3333+ <li>Violate any applicable laws or regulations</li>
3434+ <li>Infringe upon the rights of others</li>
3535+ <li>Upload, store, or share content that is illegal, harmful, threatening, abusive, harassing, defamatory, vulgar, obscene, or otherwise objectionable</li>
3636+ <li>Engage in spam, phishing, or other deceptive practices</li>
3737+ <li>Attempt to gain unauthorized access to the Service or other users' accounts</li>
3838+ <li>Interfere with or disrupt the Service or servers connected to the Service</li>
3939+ </ul>
4040+4141+ <h2>5. Content and Intellectual Property</h2>
4242+ <p>You retain ownership of the content you upload to the Service. By uploading content, you grant us a non-exclusive, worldwide, royalty-free license to use, reproduce, modify, and distribute your content as necessary to provide the Service.</p>
4343+4444+ <h2>6. Privacy</h2>
4545+ <p>Your privacy is important to us. Please review our <a href="/privacy" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">Privacy Policy</a>, which also governs your use of the Service.</p>
4646+4747+ <h2>7. Disclaimers</h2>
4848+ <p>The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make no warranties, expressed or implied, and hereby disclaim and negate all other warranties including without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights.</p>
4949+5050+ <h2>8. Limitation of Liability</h2>
5151+ <p>In no event shall Tangled, nor its directors, employees, partners, agents, suppliers, or affiliates, be liable for any indirect, incidental, special, consequential, or punitive damages, including without limitation, loss of profits, data, use, goodwill, or other intangible losses, resulting from your use of the Service.</p>
5252+5353+ <h2>9. Indemnification</h2>
5454+ <p>You agree to defend, indemnify, and hold harmless Tangled and its affiliates, officers, directors, employees, and agents from and against any and all claims, damages, obligations, losses, liabilities, costs, or debt, and expenses (including attorney's fees).</p>
5555+5656+ <h2>10. Governing Law</h2>
5757+ <p>These Terms shall be interpreted and governed by the laws of Finland, without regard to its conflict of law provisions.</p>
5858+5959+ <h2>11. Changes to Terms</h2>
6060+ <p>We reserve the right to modify or replace these Terms at any time. If a revision is material, we will try to provide at least 30 days notice prior to any new terms taking effect.</p>
6161+6262+ <h2>12. Contact Information</h2>
6363+ <p>If you have any questions about these Terms of Service, please contact us through our platform or via email.</p>
6464+6565+ <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400">
6666+ <p>These terms are effective as of the last updated date shown above and will remain in effect except with respect to any changes in their provisions in the future, which will be in effect immediately after being posted on this page.</p>
6767+ </div>
6868+ </div>
6969+ </div>
7070+</div>
7171+{{ end }}
+19-6
appview/pages/templates/repo/blob.html
···5566 {{ $title := printf "%s at %s · %s" .Path .Ref .RepoInfo.FullName }}
77 {{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }}
88-88+99 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
1010-1010+1111{{ end }}
12121313{{ define "repoContent" }}
···4444 <a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a>
4545 {{ if .RenderToggle }}
4646 <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
4747- <a
4848- href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
4747+ <a
4848+ href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
4949 hx-boost="true"
5050 >view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a>
5151 {{ end }}
5252 </div>
5353 </div>
5454 </div>
5555- {{ if .IsBinary }}
5555+ {{ if and .IsBinary .Unsupported }}
5656 <p class="text-center text-gray-400 dark:text-gray-500">
5757- This is a binary file and will not be displayed.
5757+ Previews are not supported for this file type.
5858 </p>
5959+ {{ else if .IsBinary }}
6060+ <div class="text-center">
6161+ {{ if .IsImage }}
6262+ <img src="{{ .ContentSrc }}"
6363+ alt="{{ .Path }}"
6464+ class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" />
6565+ {{ else if .IsVideo }}
6666+ <video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded">
6767+ <source src="{{ .ContentSrc }}">
6868+ Your browser does not support the video tag.
6969+ </video>
7070+ {{ end }}
7171+ </div>
5972 {{ else }}
6073 <div class="overflow-auto relative">
6174 {{ if .ShowRendered }}
···1414 "time"
15151616 "tangled.sh/tangled.sh/core/api/tangled"
1717- "tangled.sh/tangled.sh/core/appview"
1817 "tangled.sh/tangled.sh/core/appview/config"
1918 "tangled.sh/tangled.sh/core/appview/db"
2020- "tangled.sh/tangled.sh/core/appview/idresolver"
1919+ "tangled.sh/tangled.sh/core/appview/notify"
2120 "tangled.sh/tangled.sh/core/appview/oauth"
2221 "tangled.sh/tangled.sh/core/appview/pages"
2322 "tangled.sh/tangled.sh/core/appview/reporesolver"
2323+ "tangled.sh/tangled.sh/core/idresolver"
2424 "tangled.sh/tangled.sh/core/knotclient"
2525 "tangled.sh/tangled.sh/core/patchutil"
2626+ "tangled.sh/tangled.sh/core/tid"
2627 "tangled.sh/tangled.sh/core/types"
27282829 "github.com/bluekeyes/go-gitdiff/gitdiff"
···3132 lexutil "github.com/bluesky-social/indigo/lex/util"
3233 "github.com/go-chi/chi/v5"
3334 "github.com/google/uuid"
3434- "github.com/posthog/posthog-go"
3535)
36363737type Pulls struct {
···4141 idResolver *idresolver.Resolver
4242 db *db.DB
4343 config *config.Config
4444- posthog posthog.Client
4444+ notifier notify.Notifier
4545}
46464747func New(
···5151 resolver *idresolver.Resolver,
5252 db *db.DB,
5353 config *config.Config,
5454- posthog posthog.Client,
5454+ notifier notify.Notifier,
5555) *Pulls {
5656 return &Pulls{
5757 oauth: oauth,
···6060 idResolver: resolver,
6161 db: db,
6262 config: config,
6363- posthog: posthog,
6363+ notifier: notifier,
6464 }
6565}
6666···198198 m[p.Sha] = p
199199 }
200200201201+ reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt())
202202+ if err != nil {
203203+ log.Println("failed to get pull reactions")
204204+ s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
205205+ }
206206+207207+ userReactions := map[db.ReactionKind]bool{}
208208+ if user != nil {
209209+ userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
210210+ }
211211+201212 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
202213 LoggedInUser: user,
203214 RepoInfo: repoInfo,
···208219 MergeCheck: mergeCheckResponse,
209220 ResubmitCheck: resubmitResult,
210221 Pipelines: m,
222222+223223+ OrderedReactionKinds: db.OrderedReactionKinds,
224224+ Reactions: reactionCountMap,
225225+ UserReacted: userReactions,
211226 })
212227}
213228···340355 return
341356 }
342357358358+ var diffOpts types.DiffOpts
359359+ if d := r.URL.Query().Get("diff"); d == "split" {
360360+ diffOpts.Split = true
361361+ }
362362+343363 pull, ok := r.Context().Value("pull").(*db.Pull)
344364 if !ok {
345365 log.Println("failed to get pull")
···380400 Round: roundIdInt,
381401 Submission: pull.Submissions[roundIdInt],
382402 Diff: &diff,
403403+ DiffOpts: diffOpts,
383404 })
384405385406}
···393414 return
394415 }
395416417417+ var diffOpts types.DiffOpts
418418+ if d := r.URL.Query().Get("diff"); d == "split" {
419419+ diffOpts.Split = true
420420+ }
421421+396422 pull, ok := r.Context().Value("pull").(*db.Pull)
397423 if !ok {
398424 log.Println("failed to get pull")
···448474 Round: roundIdInt,
449475 DidHandleMap: didHandleMap,
450476 Interdiff: interdiff,
477477+ DiffOpts: diffOpts,
451478 })
452452- return
453479}
454480455481func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
···529555530556 // we want to group all stacked PRs into just one list
531557 stacks := make(map[string]db.Stack)
558558+ var shas []string
532559 n := 0
533560 for _, p := range pulls {
561561+ // store the sha for later
562562+ shas = append(shas, p.LatestSha())
534563 // this PR is stacked
535564 if p.StackId != "" {
536565 // we have already seen this PR stack
···549578 }
550579 pulls = pulls[:n]
551580581581+ repoInfo := f.RepoInfo(user)
582582+ ps, err := db.GetPipelineStatuses(
583583+ s.db,
584584+ db.FilterEq("repo_owner", repoInfo.OwnerDid),
585585+ db.FilterEq("repo_name", repoInfo.Name),
586586+ db.FilterEq("knot", repoInfo.Knot),
587587+ db.FilterIn("sha", shas),
588588+ )
589589+ if err != nil {
590590+ log.Printf("failed to fetch pipeline statuses: %s", err)
591591+ // non-fatal
592592+ }
593593+ m := make(map[string]db.Pipeline)
594594+ for _, p := range ps {
595595+ m[p.Sha] = p
596596+ }
597597+552598 identsToResolve := make([]string, len(pulls))
553599 for i, pull := range pulls {
554600 identsToResolve[i] = pull.OwnerDid
···570616 DidHandleMap: didHandleMap,
571617 FilteringBy: state,
572618 Stacks: stacks,
619619+ Pipelines: m,
573620 })
574574- return
575621}
576622577623func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
···642688 atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
643689 Collection: tangled.RepoPullCommentNSID,
644690 Repo: user.Did,
645645- Rkey: appview.TID(),
691691+ Rkey: tid.TID(),
646692 Record: &lexutil.LexiconTypeDecoder{
647693 Val: &tangled.RepoPullComment{
648694 Repo: &atUri,
···659705 return
660706 }
661707662662- // Create the pull comment in the database with the commentAt field
663663- commentId, err := db.NewPullComment(tx, &db.PullComment{
708708+ comment := &db.PullComment{
664709 OwnerDid: user.Did,
665710 RepoAt: f.RepoAt.String(),
666711 PullId: pull.PullId,
667712 Body: body,
668713 CommentAt: atResp.Uri,
669714 SubmissionId: pull.Submissions[roundNumber].ID,
670670- })
715715+ }
716716+717717+ // Create the pull comment in the database with the commentAt field
718718+ commentId, err := db.NewPullComment(tx, comment)
671719 if err != nil {
672720 log.Println("failed to create pull comment", err)
673721 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
···681729 return
682730 }
683731684684- if !s.config.Core.Dev {
685685- err = s.posthog.Enqueue(posthog.Capture{
686686- DistinctId: user.Did,
687687- Event: "new_pull_comment",
688688- Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pull.PullId},
689689- })
690690- if err != nil {
691691- log.Println("failed to enqueue posthog event:", err)
692692- }
693693- }
732732+ s.notifier.NewPullComment(r.Context(), comment)
694733695734 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
696735 return
···10191058 body = formatPatches[0].Body
10201059 }
1021106010221022- rkey := appview.TID()
10611061+ rkey := tid.TID()
10231062 initialSubmission := db.PullSubmission{
10241063 Patch: patch,
10251064 SourceRev: sourceRev,
10261065 }
10271027- err = db.NewPull(tx, &db.Pull{
10661066+ pull := &db.Pull{
10281067 Title: title,
10291068 Body: body,
10301069 TargetBranch: targetBranch,
···10351074 &initialSubmission,
10361075 },
10371076 PullSource: pullSource,
10381038- })
10771077+ }
10781078+ err = db.NewPull(tx, pull)
10391079 if err != nil {
10401080 log.Println("failed to create pull request", err)
10411081 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
···10751115 return
10761116 }
1077111710781078- if !s.config.Core.Dev {
10791079- err = s.posthog.Enqueue(posthog.Capture{
10801080- DistinctId: user.Did,
10811081- Event: "new_pull",
10821082- Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pullId},
10831083- })
10841084- if err != nil {
10851085- log.Println("failed to enqueue posthog event:", err)
10861086- }
10871087- }
11181118+ s.notifier.NewPull(r.Context(), pull)
1088111910891120 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
10901121}
···16471678 }
1648167916491680 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
16501650- return
16511681}
1652168216531683func (s *Pulls) resubmitStackedPullHelper(
···18911921 }
1892192218931923 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
18941894- return
18951924}
1896192518971926func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
···2015204420162045 // auth filter: only owner or collaborators can close
20172046 roles := f.RolesInRepo(user)
20472047+ isOwner := roles.IsOwner()
20182048 isCollaborator := roles.IsCollaborator()
20192049 isPullAuthor := user.Did == pull.OwnerDid
20202020- isCloseAllowed := isCollaborator || isPullAuthor
20502050+ isCloseAllowed := isOwner || isCollaborator || isPullAuthor
20212051 if !isCloseAllowed {
20222052 log.Println("failed to close pull")
20232053 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
···20612091 }
2062209220632093 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
20642064- return
20652094}
2066209520672096func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
···2083211220842113 // auth filter: only owner or collaborators can close
20852114 roles := f.RolesInRepo(user)
21152115+ isOwner := roles.IsOwner()
20862116 isCollaborator := roles.IsCollaborator()
20872117 isPullAuthor := user.Did == pull.OwnerDid
20882088- isCloseAllowed := isCollaborator || isPullAuthor
21182118+ isCloseAllowed := isOwner || isCollaborator || isPullAuthor
20892119 if !isCloseAllowed {
20902120 log.Println("failed to close pull")
20912121 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
···21292159 }
2130216021312161 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
21322132- return
21332162}
2134216321352164func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) {
···2155218421562185 title := fp.Title
21572186 body := fp.Body
21582158- rkey := appview.TID()
21872187+ rkey := tid.TID()
2159218821602189 initialSubmission := db.PullSubmission{
21612190 Patch: fp.Raw,
+2
appview/pulls/router.go
···4444 r.Get("/", s.ResubmitPull)
4545 r.Post("/", s.ResubmitPull)
4646 })
4747+ // permissions here require us to know pull author
4848+ // it is handled within the route
4749 r.Post("/close", s.ClosePull)
4850 r.Post("/reopen", s.ReopenPull)
4951 // collaborators only
···3232nix run .#watch-tailwind
3333```
34343535+To authenticate with the appview, you will need redis and
3636+OAUTH JWKs to be setup:
3737+3838+```
3939+# oauth jwks should already be setup by the nix devshell:
4040+echo $TANGLED_OAUTH_JWKS
4141+{"crv":"P-256","d":"tELKHYH-Dko6qo4ozYcVPE1ah6LvXHFV2wpcWpi8ab4","kid":"1753352226","kty":"EC","x":"mRzYpLzAGq74kJez9UbgGfV040DxgsXpMbaVsdy8RZs","y":"azqqXzUYywMlLb2Uc5AVG18nuLXyPnXr4kI4T39eeIc"}
4242+4343+# if not, you can set it up yourself:
4444+go build -o genjwks.out ./cmd/genjwks
4545+export TANGLED_OAUTH_JWKS="$(./genjwks.out)"
4646+4747+# run redis in at a new shell to store oauth sessions
4848+redis-server
4949+```
5050+3551## running a knot
36523753An end-to-end knot setup requires setting up a machine with
···3955quite cumbersome. So the nix flake provides a
4056`nixosConfiguration` to do so.
41574242-To begin, head to `http://localhost:3000` in the browser and
4343-generate a knot secret. Replace the existing secret in
4444-`flake.nix` with the newly generated secret.
5858+To begin, head to `http://localhost:3000/knots` in the browser
5959+and generate a knot secret. Set `$TANGLED_KNOT_SECRET` to it,
6060+ideally in a `.envrc` with [direnv](https://direnv.net) so you
6161+don't lose it.
45624663You can now start a lightweight NixOS VM using
4764`nixos-shell` like so:
48654966```bash
5050-QEMU_NET_OPTS="hostfwd=tcp::6000-:6000,hostfwd=tcp::2222-:22" nixos-shell --flake .#knotVM
6767+nix run .#vm
6868+# or nixos-shell --flake .#vm
51695270# hit Ctrl-a + c + q to exit the VM
5371```
54725555-This starts a knot on port 6000 with `ssh` exposed on port
5656-2222. You can push repositories to this VM with this ssh
5757-config block on your main machine:
7373+This starts a knot on port 6000, a spindle on port 6555
7474+with `ssh` exposed on port 2222. You can push repositories
7575+to this VM with this ssh config block on your main machine:
58765977```bash
6078Host nixos-shell
···7088git remote add local-dev git@nixos-shell:user/repo
7189git push local-dev main
7290```
9191+9292+## running a spindle
9393+9494+Be sure to set `$TANGLED_SPINDLE_OWNER` to your own DID.
9595+The above VM should already be running a spindle on `localhost:6555`.
9696+You can head to the spindle dashboard on `http://localhost:3000/spindles`,
9797+and register a spindle with hostname `localhost:6555`. It should instantly
9898+be verified. You can then configure each repository to use this spindle
9999+and run CI jobs.
100100+101101+Of interest when debugging spindles:
102102+103103+```
104104+# service logs from journald:
105105+journalctl -xeu spindle
106106+107107+# CI job logs from disk:
108108+ls /var/log/spindle
109109+110110+# debugging spindle db:
111111+sqlite3 /var/lib/spindle/spindle.db
112112+113113+# litecli has a nicer REPL interface:
114114+litecli /var/lib/spindle/spindle.db
115115+```
+13-1
docs/knot-hosting.md
···8989systemctl start knotserver
9090```
91919292-The last step is to configure a reverse proxy like Nginx or Caddy to front yourself
9292+The last step is to configure a reverse proxy like Nginx or Caddy to front your
9393knot. Here's an example configuration for Nginx:
94949595```
···191191```
192192193193Make sure to restart your SSH server!
194194+195195+#### MOTD (message of the day)
196196+197197+To configure the MOTD used ("Welcome to this knot!" by default), edit the
198198+`/home/git/motd` file:
199199+200200+```
201201+printf "Hi from this knot!\n" > /home/git/motd
202202+```
203203+204204+Note that you should add a newline at the end if setting a non-empty message
205205+since the knot won't do this for you.
+4-3
docs/spindle/architecture.md
···13131414### the engine
15151616-At present, the only supported backend is Docker. Spindle executes each step in
1717-the pipeline in a fresh container, with state persisted across steps within the
1818-`/tangled/workspace` directory.
1616+At present, the only supported backend is Docker (and Podman, if Docker
1717+compatibility is enabled, so that `/run/docker.sock` is created). Spindle
1818+executes each step in the pipeline in a fresh container, with state persisted
1919+across steps within the `/tangled/workspace` directory.
19202021The base image for the container is constructed on the fly using
2122[Nixery](https://nixery.dev), which is handy for caching layers for frequently
+12-3
docs/spindle/hosting.md
···31312. **Build the Spindle binary.**
32323333 ```shell
3434- go build -o spindle core/spindle/server.go
3434+ cd core
3535+ go mod download
3636+ go build -o cmd/spindle/spindle cmd/spindle/main.go
3737+ ```
3838+3939+3. **Create the log directory.**
4040+4141+ ```shell
4242+ sudo mkdir -p /var/log/spindle
4343+ sudo chown $USER:$USER -R /var/log/spindle
3544 ```
36453737-3. **Run the Spindle binary.**
4646+4. **Run the Spindle binary.**
38473948 ```shell
4040- ./spindle
4949+ ./cmd/spindle/spindle
4150 ```
42514352Spindle will now start, connect to the Jetstream server, and begin processing pipelines.
+285
docs/spindle/openbao.md
···11+# spindle secrets with openbao
22+33+This document covers setting up Spindle to use OpenBao for secrets
44+management via OpenBao Proxy instead of the default SQLite backend.
55+66+## overview
77+88+Spindle now uses OpenBao Proxy for secrets management. The proxy handles
99+authentication automatically using AppRole credentials, while Spindle
1010+connects to the local proxy instead of directly to the OpenBao server.
1111+1212+This approach provides better security, automatic token renewal, and
1313+simplified application code.
1414+1515+## installation
1616+1717+Install OpenBao from nixpkgs:
1818+1919+```bash
2020+nix shell nixpkgs#openbao # for a local server
2121+```
2222+2323+## setup
2424+2525+The setup process can is documented for both local development and production.
2626+2727+### local development
2828+2929+Start OpenBao in dev mode:
3030+3131+```bash
3232+bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201
3333+```
3434+3535+This starts OpenBao on `http://localhost:8201` with a root token.
3636+3737+Set up environment for bao CLI:
3838+3939+```bash
4040+export BAO_ADDR=http://localhost:8200
4141+export BAO_TOKEN=root
4242+```
4343+4444+### production
4545+4646+You would typically use a systemd service with a configuration file. Refer to
4747+[@tangled.sh/infra](https://tangled.sh/@tangled.sh/infra) for how this can be
4848+achieved using Nix.
4949+5050+Then, initialize the bao server:
5151+```bash
5252+bao operator init -key-shares=1 -key-threshold=1
5353+```
5454+5555+This will print out an unseal key and a root key. Save them somewhere (like a password manager). Then unseal the vault to begin setting it up:
5656+```bash
5757+bao operator unseal <unseal_key>
5858+```
5959+6060+All steps below remain the same across both dev and production setups.
6161+6262+### configure openbao server
6363+6464+Create the spindle KV mount:
6565+6666+```bash
6767+bao secrets enable -path=spindle -version=2 kv
6868+```
6969+7070+Set up AppRole authentication and policy:
7171+7272+Create a policy file `spindle-policy.hcl`:
7373+7474+```hcl
7575+# Full access to spindle KV v2 data
7676+path "spindle/data/*" {
7777+ capabilities = ["create", "read", "update", "delete"]
7878+}
7979+8080+# Access to metadata for listing and management
8181+path "spindle/metadata/*" {
8282+ capabilities = ["list", "read", "delete", "update"]
8383+}
8484+8585+# Allow listing at root level
8686+path "spindle/" {
8787+ capabilities = ["list"]
8888+}
8989+9090+# Required for connection testing and health checks
9191+path "auth/token/lookup-self" {
9292+ capabilities = ["read"]
9393+}
9494+```
9595+9696+Apply the policy and create an AppRole:
9797+9898+```bash
9999+bao policy write spindle-policy spindle-policy.hcl
100100+bao auth enable approle
101101+bao write auth/approle/role/spindle \
102102+ token_policies="spindle-policy" \
103103+ token_ttl=1h \
104104+ token_max_ttl=4h \
105105+ bind_secret_id=true \
106106+ secret_id_ttl=0 \
107107+ secret_id_num_uses=0
108108+```
109109+110110+Get the credentials:
111111+112112+```bash
113113+# Get role ID (static)
114114+ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id)
115115+116116+# Generate secret ID
117117+SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id)
118118+119119+echo "Role ID: $ROLE_ID"
120120+echo "Secret ID: $SECRET_ID"
121121+```
122122+123123+### create proxy configuration
124124+125125+Create the credential files:
126126+127127+```bash
128128+# Create directory for OpenBao files
129129+mkdir -p /tmp/openbao
130130+131131+# Save credentials
132132+echo "$ROLE_ID" > /tmp/openbao/role-id
133133+echo "$SECRET_ID" > /tmp/openbao/secret-id
134134+chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id
135135+```
136136+137137+Create a proxy configuration file `/tmp/openbao/proxy.hcl`:
138138+139139+```hcl
140140+# OpenBao server connection
141141+vault {
142142+ address = "http://localhost:8200"
143143+}
144144+145145+# Auto-Auth using AppRole
146146+auto_auth {
147147+ method "approle" {
148148+ mount_path = "auth/approle"
149149+ config = {
150150+ role_id_file_path = "/tmp/openbao/role-id"
151151+ secret_id_file_path = "/tmp/openbao/secret-id"
152152+ }
153153+ }
154154+155155+ # Optional: write token to file for debugging
156156+ sink "file" {
157157+ config = {
158158+ path = "/tmp/openbao/token"
159159+ mode = 0640
160160+ }
161161+ }
162162+}
163163+164164+# Proxy listener for Spindle
165165+listener "tcp" {
166166+ address = "127.0.0.1:8201"
167167+ tls_disable = true
168168+}
169169+170170+# Enable API proxy with auto-auth token
171171+api_proxy {
172172+ use_auto_auth_token = true
173173+}
174174+175175+# Enable response caching
176176+cache {
177177+ use_auto_auth_token = true
178178+}
179179+180180+# Logging
181181+log_level = "info"
182182+```
183183+184184+### start the proxy
185185+186186+Start OpenBao Proxy:
187187+188188+```bash
189189+bao proxy -config=/tmp/openbao/proxy.hcl
190190+```
191191+192192+The proxy will authenticate with OpenBao and start listening on
193193+`127.0.0.1:8201`.
194194+195195+### configure spindle
196196+197197+Set these environment variables for Spindle:
198198+199199+```bash
200200+export SPINDLE_SERVER_SECRETS_PROVIDER=openbao
201201+export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201
202202+export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle
203203+```
204204+205205+Start Spindle:
206206+207207+Spindle will now connect to the local proxy, which handles all
208208+authentication automatically.
209209+210210+## production setup for proxy
211211+212212+For production, you'll want to run the proxy as a service:
213213+214214+Place your production configuration in `/etc/openbao/proxy.hcl` with
215215+proper TLS settings for the vault connection.
216216+217217+## verifying setup
218218+219219+Test the proxy directly:
220220+221221+```bash
222222+# Check proxy health
223223+curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health
224224+225225+# Test token lookup through proxy
226226+curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self
227227+```
228228+229229+Test OpenBao operations through the server:
230230+231231+```bash
232232+# List all secrets
233233+bao kv list spindle/
234234+235235+# Add a test secret via Spindle API, then check it exists
236236+bao kv list spindle/repos/
237237+238238+# Get a specific secret
239239+bao kv get spindle/repos/your_repo_path/SECRET_NAME
240240+```
241241+242242+## how it works
243243+244244+- Spindle connects to OpenBao Proxy on localhost (typically port 8200 or 8201)
245245+- The proxy authenticates with OpenBao using AppRole credentials
246246+- All Spindle requests go through the proxy, which injects authentication tokens
247247+- Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}`
248248+- Repository paths like `did:plc:alice/myrepo` become `did_plc_alice_myrepo`
249249+- The proxy handles all token renewal automatically
250250+- Spindle no longer manages tokens or authentication directly
251251+252252+## troubleshooting
253253+254254+**Connection refused**: Check that the OpenBao Proxy is running and
255255+listening on the configured address.
256256+257257+**403 errors**: Verify the AppRole credentials are correct and the policy
258258+has the necessary permissions.
259259+260260+**404 route errors**: The spindle KV mount probably doesn't exist - run
261261+the mount creation step again.
262262+263263+**Proxy authentication failures**: Check the proxy logs and verify the
264264+role-id and secret-id files are readable and contain valid credentials.
265265+266266+**Secret not found after writing**: This can indicate policy permission
267267+issues. Verify the policy includes both `spindle/data/*` and
268268+`spindle/metadata/*` paths with appropriate capabilities.
269269+270270+Check proxy logs:
271271+272272+```bash
273273+# If running as systemd service
274274+journalctl -u openbao-proxy -f
275275+276276+# If running directly, check the console output
277277+```
278278+279279+Test AppRole authentication manually:
280280+281281+```bash
282282+bao write auth/approle/login \
283283+ role_id="$(cat /tmp/openbao/role-id)" \
284284+ secret_id="$(cat /tmp/openbao/secret-id)"
285285+```
+7
docs/spindle/pipeline.md
···5757 depth: 50
5858 submodules: true
5959```
6060+6161+## git push options
6262+6363+These are push options that can be used with the `--push-option (-o)` flag of git push:
6464+6565+- `verbose-ci`, `ci-verbose`: enables diagnostics reporting for the CI pipeline, allowing you to see any issues when you push.
6666+- `skip-ci`, `ci-skip`: skips triggering the CI pipeline.
···4545 unique(owner, name)
4646 );
47474848+ create table if not exists spindle_members (
4949+ -- identifiers for the record
5050+ id integer primary key autoincrement,
5151+ did text not null,
5252+ rkey text not null,
5353+5454+ -- data
5555+ instance text not null,
5656+ subject text not null,
5757+ created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
5858+5959+ -- constraints
6060+ unique (did, instance, subject)
6161+ );
6262+4863 -- status event for a single workflow
4964 create table if not exists events (
5065 rkey text not null,
+59
spindle/db/member.go
···11+package db
22+33+import (
44+ "time"
55+66+ "github.com/bluesky-social/indigo/atproto/syntax"
77+)
88+99+type SpindleMember struct {
1010+ Id int
1111+ Did syntax.DID // owner of the record
1212+ Rkey string // rkey of the record
1313+ Instance string
1414+ Subject syntax.DID // the member being added
1515+ Created time.Time
1616+}
1717+1818+func AddSpindleMember(db *DB, member SpindleMember) error {
1919+ _, err := db.Exec(
2020+ `insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`,
2121+ member.Did,
2222+ member.Rkey,
2323+ member.Instance,
2424+ member.Subject,
2525+ )
2626+ return err
2727+}
2828+2929+func RemoveSpindleMember(db *DB, owner_did, rkey string) error {
3030+ _, err := db.Exec(
3131+ "delete from spindle_members where did = ? and rkey = ?",
3232+ owner_did,
3333+ rkey,
3434+ )
3535+ return err
3636+}
3737+3838+func GetSpindleMember(db *DB, did, rkey string) (*SpindleMember, error) {
3939+ query :=
4040+ `select id, did, rkey, instance, subject, created
4141+ from spindle_members
4242+ where did = ? and rkey = ?`
4343+4444+ var member SpindleMember
4545+ var createdAt string
4646+ err := db.QueryRow(query, did, rkey).Scan(
4747+ &member.Id,
4848+ &member.Did,
4949+ &member.Rkey,
5050+ &member.Instance,
5151+ &member.Subject,
5252+ &createdAt,
5353+ )
5454+ if err != nil {
5555+ return nil, err
5656+ }
5757+5858+ return &member, nil
5959+}
+43-20
spindle/engine/engine.go
···1111 "sync"
1212 "time"
13131414+ securejoin "github.com/cyphar/filepath-securejoin"
1415 "github.com/docker/docker/api/types/container"
1516 "github.com/docker/docker/api/types/image"
1617 "github.com/docker/docker/api/types/mount"
···1819 "github.com/docker/docker/api/types/volume"
1920 "github.com/docker/docker/client"
2021 "github.com/docker/docker/pkg/stdcopy"
2222+ "golang.org/x/sync/errgroup"
2123 "tangled.sh/tangled.sh/core/log"
2224 "tangled.sh/tangled.sh/core/notifier"
2325 "tangled.sh/tangled.sh/core/spindle/config"
2426 "tangled.sh/tangled.sh/core/spindle/db"
2527 "tangled.sh/tangled.sh/core/spindle/models"
2828+ "tangled.sh/tangled.sh/core/spindle/secrets"
2629)
27302831const (
···3740 db *db.DB
3841 n *notifier.Notifier
3942 cfg *config.Config
4343+ vault secrets.Manager
40444145 cleanupMu sync.Mutex
4246 cleanup map[string][]cleanupFunc
4347}
44484545-func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier) (*Engine, error) {
4949+func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager) (*Engine, error) {
4650 dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
4751 if err != nil {
4852 return nil, err
···5660 db: db,
5761 n: n,
5862 cfg: cfg,
6363+ vault: vault,
5964 }
60656166 e.cleanup = make(map[string][]cleanupFunc)
···6671func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) {
6772 e.l.Info("starting all workflows in parallel", "pipeline", pipelineId)
68736969- wg := sync.WaitGroup{}
7474+ // extract secrets
7575+ var allSecrets []secrets.UnlockedSecret
7676+ if didSlashRepo, err := securejoin.SecureJoin(pipeline.RepoOwner, pipeline.RepoName); err == nil {
7777+ if res, err := e.vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil {
7878+ allSecrets = res
7979+ }
8080+ }
8181+8282+ workflowTimeoutStr := e.cfg.Pipelines.WorkflowTimeout
8383+ workflowTimeout, err := time.ParseDuration(workflowTimeoutStr)
8484+ if err != nil {
8585+ e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr)
8686+ workflowTimeout = 5 * time.Minute
8787+ }
8888+ e.l.Info("using workflow timeout", "timeout", workflowTimeout)
8989+9090+ eg, ctx := errgroup.WithContext(ctx)
7091 for _, w := range pipeline.Workflows {
7171- wg.Add(1)
7272- go func() error {
7373- defer wg.Done()
9292+ eg.Go(func() error {
7493 wid := models.WorkflowId{
7594 PipelineId: pipelineId,
7695 Name: w.Name,
···9010991110 reader, err := e.docker.ImagePull(ctx, w.Image, image.PullOptions{})
92111 if err != nil {
9393- e.l.Error("pipeline failed!", "workflowId", wid, "error", err.Error())
112112+ e.l.Error("pipeline image pull failed!", "image", w.Image, "workflowId", wid, "error", err.Error())
9411395114 err := e.db.StatusFailed(wid, err.Error(), -1, e.n)
96115 if err != nil {
···102121 defer reader.Close()
103122 io.Copy(os.Stdout, reader)
104123105105- workflowTimeoutStr := e.cfg.Pipelines.WorkflowTimeout
106106- workflowTimeout, err := time.ParseDuration(workflowTimeoutStr)
107107- if err != nil {
108108- e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr)
109109- workflowTimeout = 5 * time.Minute
110110- }
111111- e.l.Info("using workflow timeout", "timeout", workflowTimeout)
112124 ctx, cancel := context.WithTimeout(ctx, workflowTimeout)
113125 defer cancel()
114126115115- err = e.StartSteps(ctx, w.Steps, wid, w.Image)
127127+ err = e.StartSteps(ctx, wid, w, allSecrets)
116128 if err != nil {
117129 if errors.Is(err, ErrTimedOut) {
118130 dbErr := e.db.StatusTimeout(wid, e.n)
···135147 }
136148137149 return nil
138138- }()
150150+ })
139151 }
140152141141- wg.Wait()
153153+ if err = eg.Wait(); err != nil {
154154+ e.l.Error("failed to run one or more workflows", "err", err)
155155+ } else {
156156+ e.l.Error("successfully ran full pipeline")
157157+ }
142158}
143159144160// SetupWorkflow sets up a new network for the workflow and volumes for
···186202// ONLY marks pipeline as failed if container's exit code is non-zero.
187203// All other errors are bubbled up.
188204// Fixed version of the step execution logic
189189-func (e *Engine) StartSteps(ctx context.Context, steps []models.Step, wid models.WorkflowId, image string) error {
205205+func (e *Engine) StartSteps(ctx context.Context, wid models.WorkflowId, w models.Workflow, secrets []secrets.UnlockedSecret) error {
206206+ workflowEnvs := ConstructEnvs(w.Environment)
207207+ for _, s := range secrets {
208208+ workflowEnvs.AddEnv(s.Key, s.Value)
209209+ }
190210191191- for stepIdx, step := range steps {
211211+ for stepIdx, step := range w.Steps {
192212 select {
193213 case <-ctx.Done():
194214 return ctx.Err()
195215 default:
196216 }
197217198198- envs := ConstructEnvs(step.Environment)
218218+ envs := append(EnvVars(nil), workflowEnvs...)
219219+ for k, v := range step.Environment {
220220+ envs.AddEnv(k, v)
221221+ }
199222 envs.AddEnv("HOME", workspaceDir)
200223 e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice())
201224202225 hostConfig := hostConfig(wid)
203226 resp, err := e.docker.ContainerCreate(ctx, &container.Config{
204204- Image: image,
227227+ Image: w.Image,
205228 Cmd: []string{"bash", "-c", step.Command},
206229 WorkingDir: workspaceDir,
207230 Tty: false,