refactor(website): drop /adopters page and move discovery tool out of repo

Drops the dedicated /adopters page, its Vue component, the navbar entry,
the sidebar hack, and the contributing-guide section. The homepage
carousel already carries the social-proof signal — a separate page
attracted virtually no traffic on comparable OSS sites and added
maintenance surface without a clear payoff.

Also removes the "See all" CTA from the carousel header now that there
is nowhere to send visitors to, and centers the remaining label.

The find-adopters Go tool moves out of the repo (to ../find-adopters/)
— it was always a one-off analysis helper, not code that ships with
Task. The adopters.ts file remains the submission surface for anyone
motivated enough to PR a new entry.
This commit is contained in:
Valentin Maerten
2026-04-19 14:09:21 +02:00
parent 4bee0c6d66
commit bc755b8391
10 changed files with 7 additions and 1184 deletions

View File

@@ -1,443 +0,0 @@
<script setup lang="ts">
import { adopters } from '../adopters';
const pad = (n: number) => String(n).padStart(2, '0');
const githubPath = (url: string) =>
url.replace(/^https?:\/\/github\.com\//, '').replace(/\/$/, '');
</script>
<template>
<div class="adopters">
<header class="intro">
<p class="kicker">
<span class="slashes">//</span>
{{ pad(adopters.length) }} projects and counting
</p>
<h1 class="title">Built with Task.</h1>
<p class="lede">
A curated list of open source projects that rely on Task for their build
and release workflows. From hardware toolchains to AI frameworks, Task
powers the command line of teams worldwide.
</p>
</header>
<section class="grid">
<a
v-for="(item, i) in adopters"
:key="item.name"
:href="item.url"
target="_blank"
rel="noopener"
class="card"
>
<span class="corner tl"></span>
<span class="corner tr"></span>
<span class="corner bl"></span>
<span class="corner br"></span>
<div class="card-head">
<img :src="item.img" :alt="`${item.name} logo`" class="card-logo" />
<span class="card-index">N&deg; {{ pad(i + 1) }}</span>
</div>
<h2 class="card-name">{{ item.name }}</h2>
<div class="card-foot">
<span class="card-path">{{ githubPath(item.url) }}</span>
<span class="card-cta">
<span class="cta-label">View</span>
<span class="cta-arrow">&rarr;</span>
</span>
</div>
</a>
</section>
<aside class="cta">
<div class="cta-body">
<p class="cta-kicker">
<span class="slashes">//</span>
Using Task in your project?
</p>
<h3 class="cta-title">Add your project.</h3>
<p class="cta-text">
Open a pull request updating
<code>.vitepress/adopters.ts</code> the only requirement is that
Task is the task runner for your open source project.
</p>
</div>
<a
class="cta-button"
href="https://github.com/go-task/task/blob/main/website/.vitepress/adopters.ts"
target="_blank"
rel="noopener"
>
<span>Edit adopters.ts</span>
<span class="cta-arrow">&rarr;</span>
</a>
</aside>
</div>
</template>
<style scoped>
.adopters {
max-width: 1152px;
margin: 0 auto;
padding: 0 24px 6rem;
}
/* ---------- Intro ---------- */
.intro {
padding: 3rem 0 4rem;
max-width: 42rem;
}
.kicker {
font-family: var(--vp-font-family-mono);
font-size: 0.8rem;
font-weight: 500;
letter-spacing: 0.04em;
color: var(--vp-c-text-2);
text-transform: uppercase;
margin: 0 0 1rem;
}
.slashes {
color: var(--vp-c-brand-1);
margin-right: 0.4em;
}
.title {
font-size: clamp(2.25rem, 5vw, 3.5rem);
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1;
margin: 0 0 1.5rem;
color: var(--vp-c-text-1);
}
.lede {
font-size: 1.1rem;
line-height: 1.6;
color: var(--vp-c-text-2);
margin: 0;
}
/* ---------- Grid ---------- */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
/* ---------- Card ---------- */
.card {
position: relative;
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1.5rem;
border: 1px solid var(--vp-c-divider);
border-radius: 14px;
background: var(--vp-c-bg-soft);
color: var(--vp-c-text-1);
text-decoration: none !important;
transition:
border-color 0.3s ease,
background 0.3s ease,
transform 0.3s ease,
box-shadow 0.3s ease;
isolation: isolate;
overflow: hidden;
}
.card::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(
600px circle at var(--x, 50%) var(--y, 50%),
color-mix(in srgb, var(--vp-c-brand-1) 12%, transparent),
transparent 40%
);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
z-index: -1;
}
.card:hover {
border-color: color-mix(
in srgb,
var(--vp-c-brand-1) 50%,
var(--vp-c-divider)
);
transform: translateY(-2px);
box-shadow: 0 14px 40px -24px
color-mix(in srgb, var(--vp-c-brand-1) 40%, transparent);
}
.card:hover::before {
opacity: 1;
}
/* Crosshair corner marks */
.corner {
position: absolute;
width: 10px;
height: 10px;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.corner::before,
.corner::after {
content: '';
position: absolute;
background: var(--vp-c-brand-1);
}
.corner::before {
width: 10px;
height: 1px;
top: 50%;
left: 0;
}
.corner::after {
width: 1px;
height: 10px;
top: 0;
left: 50%;
}
.corner.tl {
top: 6px;
left: 6px;
}
.corner.tr {
top: 6px;
right: 6px;
}
.corner.bl {
bottom: 6px;
left: 6px;
}
.corner.br {
bottom: 6px;
right: 6px;
}
.card:hover .corner {
opacity: 0.8;
}
/* Card head */
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.card-logo {
width: 44px;
height: 44px;
border-radius: 10px;
object-fit: cover;
background: #fff;
flex-shrink: 0;
}
.card-index {
font-family: var(--vp-font-family-mono);
font-size: 0.75rem;
letter-spacing: 0.05em;
color: var(--vp-c-text-3);
font-variant-numeric: tabular-nums;
transition: color 0.3s ease;
}
.card:hover .card-index {
color: var(--vp-c-brand-1);
}
/* Card name */
.card-name {
font-size: 1.15rem;
font-weight: 600;
letter-spacing: -0.015em;
line-height: 1.2;
margin: 0;
}
/* Card foot */
.card-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-top: auto;
padding-top: 0.25rem;
border-top: 1px dashed var(--vp-c-divider);
padding-top: 1rem;
}
.card-path {
font-family: var(--vp-font-family-mono);
font-size: 0.75rem;
color: var(--vp-c-text-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1;
}
.card-cta {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-family: var(--vp-font-family-mono);
font-size: 0.75rem;
color: var(--vp-c-text-2);
transition: color 0.3s ease;
flex-shrink: 0;
}
.card:hover .card-cta {
color: var(--vp-c-brand-1);
}
.cta-arrow {
display: inline-block;
transition: transform 0.3s ease;
}
.card:hover .cta-arrow {
transform: translateX(3px);
}
/* ---------- Add your project ---------- */
.cta {
margin-top: 3.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 2rem;
padding: 2rem 2rem 2rem 2.25rem;
border: 1px solid var(--vp-c-divider);
border-radius: 14px;
background:
linear-gradient(
135deg,
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent) 0%,
transparent 60%
),
var(--vp-c-bg-soft);
position: relative;
overflow: hidden;
}
.cta::before {
content: '';
position: absolute;
top: -1px;
left: 2rem;
right: 2rem;
height: 1px;
background: linear-gradient(
90deg,
transparent,
var(--vp-c-brand-1),
transparent
);
opacity: 0.5;
}
.cta-body {
flex: 1;
min-width: 0;
}
.cta-kicker {
font-family: var(--vp-font-family-mono);
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.04em;
color: var(--vp-c-text-2);
text-transform: uppercase;
margin: 0 0 0.5rem;
}
.cta-title {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.02em;
margin: 0 0 0.5rem;
color: var(--vp-c-text-1);
}
.cta-text {
font-size: 0.95rem;
line-height: 1.55;
color: var(--vp-c-text-2);
margin: 0;
}
.cta-text code {
font-family: var(--vp-font-family-mono);
font-size: 0.85rem;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: var(--vp-c-bg-alt);
color: var(--vp-c-brand-1);
}
.cta-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
border: 1px solid var(--vp-c-brand-1);
border-radius: 999px;
font-family: var(--vp-font-family-mono);
font-size: 0.85rem;
color: var(--vp-c-brand-1);
background: transparent;
text-decoration: none !important;
transition:
background 0.25s ease,
color 0.25s ease,
transform 0.25s ease;
flex-shrink: 0;
}
.cta-button:hover {
background: var(--vp-c-brand-1);
color: var(--vp-c-bg);
transform: translateY(-2px);
}
.cta-button .cta-arrow {
transition: transform 0.25s ease;
}
.cta-button:hover .cta-arrow {
transform: translateX(4px);
}
/* ---------- Responsive ---------- */
@media (max-width: 720px) {
.cta {
flex-direction: column;
align-items: flex-start;
padding: 1.75rem;
}
.cta-button {
align-self: stretch;
justify-content: center;
}
}
</style>

View File

@@ -6,15 +6,10 @@ const loop = [...adopters, ...adopters];
<template>
<section class="adopters-carousel">
<div class="header">
<span class="label">
<span class="slashes">//</span>
Trusted by open source projects
</span>
<a class="see-all" href="/adopters">
See all <span class="arrow">&rarr;</span>
</a>
</div>
<p class="label">
<span class="slashes">//</span>
Trusted by open source projects
</p>
<div class="viewport">
<div class="track">
@@ -42,16 +37,6 @@ const loop = [...adopters, ...adopters];
padding: 0 24px;
}
.header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
max-width: 1152px;
margin: 0 auto 2rem;
padding: 0 8px;
}
.label {
font-family: var(--vp-font-family-mono);
font-size: 0.8rem;
@@ -59,6 +44,8 @@ const loop = [...adopters, ...adopters];
letter-spacing: 0.04em;
color: var(--vp-c-text-2);
text-transform: uppercase;
text-align: center;
margin: 0 0 2rem;
}
.slashes {
@@ -66,28 +53,6 @@ const loop = [...adopters, ...adopters];
margin-right: 0.4em;
}
.see-all {
font-family: var(--vp-font-family-mono);
font-size: 0.8rem;
color: var(--vp-c-text-2);
text-decoration: none !important;
transition: color 0.2s ease;
white-space: nowrap;
}
.see-all:hover {
color: var(--vp-c-brand-1);
}
.see-all .arrow {
display: inline-block;
transition: transform 0.25s ease;
}
.see-all:hover .arrow {
transform: translateX(3px);
}
.viewport {
overflow: hidden;
-webkit-mask-image: linear-gradient(
@@ -188,11 +153,6 @@ const loop = [...adopters, ...adopters];
}
@media (max-width: 640px) {
.header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.adopters-carousel {
margin-top: 3.5rem;
}

View File

@@ -128,7 +128,6 @@ export default defineConfig({
'index.md',
'team.md',
'donate.md',
'adopters.md',
'docs/styleguide.md',
'docs/contributing.md',
'docs/releasing.md',
@@ -183,7 +182,6 @@ export default defineConfig({
activeMatch: '^/docs'
},
{ text: 'Blog', link: '/blog', activeMatch: '^/blog' },
{ text: 'Adopters', link: '/adopters' },
{ text: 'Donate', link: '/donate' },
{ text: 'Team', link: '/team' },
{
@@ -379,8 +377,7 @@ export default defineConfig({
],
// Hacky to disable sidebar for these pages
'/donate': [],
'/team': [],
'/adopters': []
'/team': []
},
socialLinks: [

View File

@@ -5,7 +5,6 @@ import HomePage from '../components/HomePage.vue';
import AuthorCard from '../components/AuthorCard.vue';
import BlogPost from '../components/BlogPost.vue';
import Version from '../components/Version.vue';
import Adopters from '../components/Adopters.vue';
import { enhanceAppWithTabs } from 'vitepress-plugin-tabs/client';
import { h } from 'vue';
import 'virtual:group-icons.css';
@@ -22,7 +21,6 @@ export default {
app.component('AuthorCard', AuthorCard);
app.component('BlogPost', BlogPost);
app.component('Version', Version);
app.component('Adopters', Adopters);
app.component('CopyOrDownloadAsMarkdownButtons', CopyOrDownloadAsMarkdownButtons);
enhanceAppWithTabs(app);
}

View File

@@ -55,9 +55,3 @@ tasks:
desc: Build and deploy taskfile.dev
cmds:
- pnpm netlify deploy --prod --site=e625bc6a-1cd3-465d-ad30-7bbddaeb4f31
find-adopters:
desc: Scan GitHub for every public repo using Task and output a ranked list
dir: scripts/find-adopters
cmds:
- go run . {{.CLI_ARGS}}

View File

@@ -1,80 +0,0 @@
# find-adopters
A small Go tool that scans GitHub for every public repository containing a
`Taskfile.yml` or `Taskfile.yaml` and produces a ranked list of adopter
candidates for the [taskfile.dev](https://taskfile.dev) "Used by" section.
## How it works
GitHub Code Search caps at 1000 results per query and only accepts a narrow
set of qualifiers alongside `filename:` — notably `stars:`, `language:`, and
`pushed:` don't combine, and `size:` does but its `total_count` isn't monotone
as ranges shrink, which makes partitioning unreliable. So the tool takes a
pragmatic two-pronged approach:
1. **Global best-match pagination** — paginate `filename:Taskfile.yml`,
`Taskfile.yaml`, `Taskfile.dist.yml`, and `Taskfile.dist.yaml` directly up
to the 1000-result cap. Captures the top ~900 best-ranked hits per variant.
2. **Per-org scan** — iterate a built-in list of ~100 well-known organizations
(hyperscalers, OSS vendors, DevOps platforms, etc.) with
`filename:Taskfile.yml org:<name>`. Captures every Taskfile inside those
orgs even when their repos don't rank in the global top.
The union is deduplicated and enriched via batched GraphQL calls (stars,
description, owner type, language, topics), then sorted by stars.
A full scan typically takes 15-25 minutes — about 120 Code Search calls at the
10 req/min authenticated rate limit, plus a handful of GraphQL batches.
### Coverage caveat
GitHub's hard 1000-result cap on the Code Search API means this tool cannot
enumerate every Taskfile on GitHub — only the best-ranked slice plus the
curated orgs. For truly exhaustive coverage, consider
[GH Archive](https://www.gharchive.org/) or the BigQuery public GitHub
dataset, which are out of scope here.
## Usage
```sh
# From this directory:
go run . -v # full scan → adopters-scan.tsv
go run . --min-stars 100 -v # only >=100 stars
go run . --owner-type org --json -o orgs.json
```
Or from the website root (if the Taskfile task is installed):
```sh
task find-adopters -- --min-stars 100 -v
```
## Flags
| Flag | Default | Description |
| --------------------- | -------------------- | ------------------------------------ |
| `-o` | `adopters-scan.tsv` | output path |
| `--json` | `false` | emit JSON instead of TSV |
| `--min-stars` | `0` | filter results below threshold |
| `--include-forks` | `false` | include forked repos |
| `--include-archived` | `false` | include archived repos |
| `--owner-type` | `any` | `org`, `user`, or `any` |
| `-v` | `false` | verbose progress logging |
## Auth
A GitHub token is required. In order of precedence:
1. `GITHUB_TOKEN` env var
2. `gh auth token` (requires the [GitHub CLI](https://cli.github.com/))
The token needs no special scopes for public data.
## Output (TSV)
```
stars full_name owner_type language url description
13619 OJ/gobuster User Go https://github.com/OJ/gobuster Directory/File...
10918 FerretDB/FerretDB Organization Go https://github.com/FerretDB/FerretDB A truly Open Source MongoDB alternative
...
```

View File

@@ -1,3 +0,0 @@
module find-adopters
go 1.25

View File

@@ -1,578 +0,0 @@
// find-adopters scans GitHub for every public repository containing a Taskfile
// and produces a ranked list of adopter candidates.
//
// GitHub Code Search caps results at 1000 per query. This tool partitions
// queries by star buckets (and, if needed, by pushed-date ranges) to cover the
// full population, then enriches every hit with GraphQL (stars, description,
// owner type, language) before sorting by popularity.
//
// Usage:
//
// find-adopters [flags]
//
// Auth: set GITHUB_TOKEN, or have `gh auth login` configured locally.
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"sort"
"strings"
"sync"
"time"
)
// ----- Config / flags -----
type config struct {
output string
emitJSON bool
minStars int
includeForks bool
includeArchived bool
ownerType string
verbose bool
}
func parseFlags() config {
var c config
flag.StringVar(&c.output, "o", "adopters-scan.tsv", "output path")
flag.BoolVar(&c.emitJSON, "json", false, "emit JSON instead of TSV")
flag.IntVar(&c.minStars, "min-stars", 0, "filter results below threshold")
flag.BoolVar(&c.includeForks, "include-forks", false, "include forked repos")
flag.BoolVar(&c.includeArchived, "include-archived", false, "include archived repos")
flag.StringVar(&c.ownerType, "owner-type", "any", "filter by owner type: org|user|any")
flag.BoolVar(&c.verbose, "v", false, "verbose progress logging")
flag.Parse()
return c
}
// ----- Auth -----
func resolveToken() (string, error) {
if t := os.Getenv("GITHUB_TOKEN"); t != "" {
return t, nil
}
out, err := exec.Command("gh", "auth", "token").Output()
if err != nil {
return "", fmt.Errorf("no GITHUB_TOKEN env var and `gh auth token` failed: %w", err)
}
return strings.TrimSpace(string(out)), nil
}
// ----- HTTP client with rate limiting -----
type client struct {
http *http.Client
token string
verbose bool
searchMu sync.Mutex
searchLast time.Time
searchGap time.Duration // minimum gap between code-search requests
}
func newClient(token string, verbose bool) *client {
return &client{
http: &http.Client{Timeout: 60 * time.Second},
token: token,
verbose: verbose,
searchGap: 7 * time.Second, // ~8.5 req/min, under the 10/min cap
}
}
func (c *client) logf(format string, args ...any) {
if c.verbose {
fmt.Fprintf(os.Stderr, "[find-adopters] "+format+"\n", args...)
}
}
func (c *client) throttleSearch() {
c.searchMu.Lock()
defer c.searchMu.Unlock()
if wait := c.searchGap - time.Since(c.searchLast); wait > 0 {
time.Sleep(wait)
}
c.searchLast = time.Now()
}
func (c *client) do(req *http.Request) (*http.Response, error) {
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
const attempts = 5
for i := 0; i < attempts; i++ {
resp, err := c.http.Do(req)
if err != nil {
time.Sleep(time.Duration(i+1) * time.Second)
continue
}
// Respect secondary rate limits + 5xx backoff.
if resp.StatusCode == 403 || resp.StatusCode == 429 || resp.StatusCode >= 500 {
resp.Body.Close()
wait := time.Duration(1<<i) * time.Second
if retry := resp.Header.Get("Retry-After"); retry != "" {
var secs int
fmt.Sscanf(retry, "%d", &secs)
if secs > 0 {
wait = time.Duration(secs) * time.Second
}
}
c.logf("backoff %s (status=%d)", wait, resp.StatusCode)
time.Sleep(wait)
continue
}
return resp, nil
}
return nil, fmt.Errorf("giving up after %d attempts", attempts)
}
// ----- Code search (discovery) -----
type searchResp struct {
TotalCount int `json:"total_count"`
Incomplete bool `json:"incomplete_results"`
Items []struct {
Repository struct {
FullName string `json:"full_name"`
} `json:"repository"`
} `json:"items"`
}
func (c *client) searchCode(q string, page int) (*searchResp, error) {
c.throttleSearch()
url := fmt.Sprintf(
"https://api.github.com/search/code?q=%s&per_page=100&page=%d",
urlEscape(q), page,
)
req, _ := http.NewRequest("GET", url, nil)
resp, err := c.do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("search %q: %s: %s", q, resp.Status, body)
}
var sr searchResp
if err := json.NewDecoder(resp.Body).Decode(&sr); err != nil {
return nil, err
}
return &sr, nil
}
// urlEscape is a tiny URL-query-safe encoder. GitHub accepts `+` as space and
// `%`-encodes special chars; we just need the common characters.
func urlEscape(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-' || r == '_' || r == '.' || r == '~':
b.WriteRune(r)
case r == ' ':
b.WriteRune('+')
default:
fmt.Fprintf(&b, "%%%02X", r)
}
}
return b.String()
}
// paginateQuery pages through up to 1000 results. If total_count is above the
// cap reachable by pageLimit pages, it still paginates — callers that want to
// avoid wasted calls and subdivide instead should check total beforehand by
// passing pageLimit=1.
func (c *client) paginateQuery(q string, pageLimit int) (repos []string, total int, err error) {
first, err := c.searchCode(q, 1)
if err != nil {
return nil, 0, err
}
total = first.TotalCount
if total == 0 {
return nil, 0, nil
}
for _, it := range first.Items {
repos = append(repos, it.Repository.FullName)
}
pages := (total + 99) / 100
if pages > pageLimit {
pages = pageLimit
}
for page := 2; page <= pages; page++ {
sr, err := c.searchCode(q, page)
if err != nil {
return repos, total, err
}
for _, it := range sr.Items {
repos = append(repos, it.Repository.FullName)
}
if len(sr.Items) < 100 {
break
}
}
return repos, total, nil
}
// GitHub Code Search caps at 1000 results per query and is unreliable with
// the `size:` qualifier (total_count is non-monotone as ranges shrink), so
// partitioning tricks don't work cleanly. We instead combine two strategies:
//
// 1. Paginate each Taskfile variant directly — gets ~900 top-ranked hits per
// variant (the "best match" slice GitHub surfaces).
// 2. Iterate a curated list of well-known organizations with an explicit
// `org:` qualifier — gets full coverage inside big brands even when their
// repos don't rank in the global top 900.
//
// The union is deduplicated and enriched via GraphQL.
// knownOrgs is a snapshot of organizations worth scanning explicitly. Adding
// here captures every Taskfile inside the org regardless of its global rank.
// Loosely ordered from most likely to least.
var knownOrgs = []string{
// Hyperscalers / clouds
"docker", "microsoft", "google", "GoogleCloudPlatform", "aws", "awslabs",
"aws-samples", "amazon-science", "Azure", "Azure-Samples",
// Infra / DevOps vendors
"hashicorp", "hashicorp-forge", "vercel", "cloudflare", "digitalocean",
"heroku", "JetBrains", "pulumi", "buildkite", "circleci", "dagger",
"temporalio", "encoredev", "argoproj", "fluxcd", "flux-framework",
// Dev tools / platforms
"netflix", "shopify", "airbnb", "uber", "lyft", "stripe", "github",
"gitlabhq", "atlassian", "RedHat", "RedHatOfficial", "openshift",
// Communication / consumer
"spotify", "slackapi", "discord", "figma", "linear", "twilio", "segmentio",
// Data / ML
"prisma", "supabase", "railwayapp", "superfly", "fly-apps", "planetscale",
"tailscale", "coder", "anthropics", "openai", "huggingface",
"pytorch", "tensorflow",
// Observability / CNCF
"grafana", "prometheus", "envoyproxy", "getsentry", "sentry", "cncf",
"helm", "istio", "linkerd", "traefik", "caddyserver",
// Frontend frameworks
"vitejs", "biomejs", "sveltejs", "vuejs", "reactjs", "astro", "nuxt",
// Databases
"mongodb-labs", "redis", "neo4j", "elastic", "influxdata", "timescale",
"clickhouse", "FerretDB",
// Go ecosystem / popular OSS
"goreleaser", "spf13", "urfave", "charmbracelet", "nodejs", "golang",
"rust-lang", "python", "apache", "etcd-io", "grpc", "arduino",
// Data eng
"dbt-labs", "astronomer", "prefecthq",
}
// discover walks every Taskfile variant with global pagination plus per-org
// scans, and returns unique owner/name pairs.
func (c *client) discover() (map[string]struct{}, error) {
uniq := make(map[string]struct{})
variants := []string{
"Taskfile.yml",
"Taskfile.yaml",
"Taskfile.dist.yml",
"Taskfile.dist.yaml",
}
c.logf("phase: global pagination (best-match top ~900 per variant)")
for _, v := range variants {
q := fmt.Sprintf("filename:%s", v)
c.logf(" query: %s", q)
repos, total, err := c.paginateQuery(q, 10)
if err != nil {
fmt.Fprintf(os.Stderr, "warn: variant %s: %v\n", v, err)
continue
}
c.logf(" total=%d collected=%d", total, len(repos))
for _, r := range repos {
uniq[r] = struct{}{}
}
}
c.logf("phase: per-org scan (%d orgs)", len(knownOrgs))
for _, org := range knownOrgs {
q := fmt.Sprintf("filename:Taskfile.yml org:%s", org)
repos, total, err := c.paginateQuery(q, 10)
if err != nil {
// Orgs that don't exist return 404 — log once and move on.
c.logf(" skip %s: %v", org, err)
continue
}
if total == 0 {
continue
}
c.logf(" org:%s total=%d collected=%d", org, total, len(repos))
for _, r := range repos {
uniq[r] = struct{}{}
}
}
return uniq, nil
}
// ----- Enrichment (GraphQL) -----
type Adopter struct {
FullName string `json:"full_name"`
Stars int `json:"stars"`
IsFork bool `json:"is_fork"`
IsArchived bool `json:"is_archived"`
OwnerType string `json:"owner_type"` // "Organization" or "User"
Language string `json:"language"`
Description string `json:"description"`
URL string `json:"url"`
PushedAt string `json:"pushed_at"`
Topics []string `json:"topics"`
}
func (c *client) enrichBatch(repos []string) ([]Adopter, error) {
var b strings.Builder
b.WriteString("query {")
for i, r := range repos {
parts := strings.SplitN(r, "/", 2)
if len(parts) != 2 {
continue
}
owner := jsonEscape(parts[0])
name := jsonEscape(parts[1])
fmt.Fprintf(&b, ` r%d: repository(owner: "%s", name: "%s") {
nameWithOwner stargazerCount isFork isArchived pushedAt url
owner { __typename }
description
primaryLanguage { name }
repositoryTopics(first: 10) { nodes { topic { name } } }
}`, i, owner, name)
}
b.WriteString(" }")
payload := map[string]string{"query": b.String()}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "https://api.github.com/graphql", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := c.do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
rb, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("graphql: %s: %s", resp.Status, rb)
}
var g struct {
Data map[string]*struct {
NameWithOwner string `json:"nameWithOwner"`
StargazerCount int `json:"stargazerCount"`
IsFork bool `json:"isFork"`
IsArchived bool `json:"isArchived"`
PushedAt string `json:"pushedAt"`
URL string `json:"url"`
Owner struct {
TypeName string `json:"__typename"`
} `json:"owner"`
Description string `json:"description"`
PrimaryLanguage *struct {
Name string `json:"name"`
} `json:"primaryLanguage"`
RepositoryTopics struct {
Nodes []struct {
Topic struct {
Name string `json:"name"`
} `json:"topic"`
} `json:"nodes"`
} `json:"repositoryTopics"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&g); err != nil {
return nil, err
}
var out []Adopter
for _, v := range g.Data {
if v == nil {
continue
}
a := Adopter{
FullName: v.NameWithOwner,
Stars: v.StargazerCount,
IsFork: v.IsFork,
IsArchived: v.IsArchived,
OwnerType: v.Owner.TypeName,
Description: v.Description,
URL: v.URL,
PushedAt: v.PushedAt,
}
if v.PrimaryLanguage != nil {
a.Language = v.PrimaryLanguage.Name
}
for _, n := range v.RepositoryTopics.Nodes {
a.Topics = append(a.Topics, n.Topic.Name)
}
out = append(out, a)
}
return out, nil
}
func jsonEscape(s string) string {
return strings.ReplaceAll(s, `"`, `\"`)
}
// enrichAll runs batched GraphQL enrichment with a small worker pool.
func (c *client) enrichAll(repos []string) []Adopter {
const (
batchSize = 50
workers = 4
)
batches := make([][]string, 0, (len(repos)+batchSize-1)/batchSize)
for i := 0; i < len(repos); i += batchSize {
end := i + batchSize
if end > len(repos) {
end = len(repos)
}
batches = append(batches, repos[i:end])
}
c.logf("enriching %d repos in %d batches (%d workers)", len(repos), len(batches), workers)
var (
out []Adopter
mu sync.Mutex
wg sync.WaitGroup
jobs = make(chan []string)
done int
)
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for batch := range jobs {
result, err := c.enrichBatch(batch)
if err != nil {
fmt.Fprintf(os.Stderr, "warn: enrich batch: %v\n", err)
continue
}
mu.Lock()
out = append(out, result...)
done++
if c.verbose {
fmt.Fprintf(os.Stderr, "[find-adopters] enriched %d/%d batches\n", done, len(batches))
}
mu.Unlock()
}
}()
}
for _, b := range batches {
jobs <- b
}
close(jobs)
wg.Wait()
return out
}
// ----- Output -----
func writeTSV(w io.Writer, data []Adopter) error {
fmt.Fprintln(w, "stars\tfull_name\towner_type\tlanguage\turl\tdescription")
for _, a := range data {
desc := strings.ReplaceAll(a.Description, "\t", " ")
desc = strings.ReplaceAll(desc, "\n", " ")
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n",
a.Stars, a.FullName, a.OwnerType, a.Language, a.URL, desc)
}
return nil
}
func writeJSON(w io.Writer, data []Adopter) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(data)
}
// ----- Main -----
func main() {
cfg := parseFlags()
token, err := resolveToken()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
cli := newClient(token, cfg.verbose)
start := time.Now()
cli.logf("phase: discover")
uniq, err := cli.discover()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
cli.logf("discovered %d unique repos in %s", len(uniq), time.Since(start).Round(time.Second))
repos := make([]string, 0, len(uniq))
for r := range uniq {
repos = append(repos, r)
}
sort.Strings(repos)
cli.logf("phase: enrich")
enriched := cli.enrichAll(repos)
// Filter
filtered := enriched[:0]
for _, a := range enriched {
if !cfg.includeForks && a.IsFork {
continue
}
if !cfg.includeArchived && a.IsArchived {
continue
}
if a.Stars < cfg.minStars {
continue
}
switch cfg.ownerType {
case "org":
if a.OwnerType != "Organization" {
continue
}
case "user":
if a.OwnerType != "User" {
continue
}
}
filtered = append(filtered, a)
}
sort.Slice(filtered, func(i, j int) bool {
return filtered[i].Stars > filtered[j].Stars
})
f, err := os.Create(cfg.output)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer f.Close()
if cfg.emitJSON {
err = writeJSON(f, filtered)
} else {
err = writeTSV(f, filtered)
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "wrote %d rows to %s (total=%d, filtered=%d, elapsed=%s)\n",
len(filtered), cfg.output, len(enriched), len(filtered), time.Since(start).Round(time.Second))
}

View File

@@ -1,8 +0,0 @@
---
title: Adopters
description:
Open source projects that use Task for their build and release workflows.
layout: page
---
<Adopters />

View File

@@ -125,20 +125,6 @@ Reference][cli-reference]. New fields also need to be added to the [Schema
Reference][schema-reference] and [JSON Schema][json-schema]. The descriptions
for fields in the docs and the schema should match.
### Adding your project to the Adopters list
If your open source project uses Task as its task runner, feel free to add it to
the [Adopters page](/adopters). Open a pull request updating
`website/.vitepress/adopters.ts` with a new entry following the existing format
(`name`, `url`, `img`). GitHub organization avatars
(`https://github.com/<org>.png`) work well as logos. For better control, you can
also drop an SVG in `website/src/public/img/adopters/` and reference it via
`/img/adopters/<file>.svg`.
Inclusion criteria: the project must be a public open source repository where
Task is used as the task runner (presence of a `Taskfile.yml` at the root, or
clearly documented usage).
### Writing tests
A lot of Task's tests are held in the `task_test.go` file in the project root