mirror of
https://github.com/go-task/task.git
synced 2026-05-18 13:15:41 +02:00
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:
@@ -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° {{ 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">→</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">→</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>
|
||||
@@ -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">→</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;
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
|
||||
...
|
||||
```
|
||||
@@ -1,3 +0,0 @@
|
||||
module find-adopters
|
||||
|
||||
go 1.25
|
||||
@@ -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))
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
title: Adopters
|
||||
description:
|
||||
Open source projects that use Task for their build and release workflows.
|
||||
layout: page
|
||||
---
|
||||
|
||||
<Adopters />
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user