# Sources

Repodar starts from what a repository declares in its files and then lets the platforms that
actually run it correct the picture. A Laravel app that says `"php": "^8.2"` in `composer.json` but
runs on PHP 8.4 on Forge is shown, and EOL-checked, as PHP 8.4.

Every value carries a confidence. When two sources disagree, the higher one wins:

| Confidence | Comes from | Example |
| --- | --- | --- |
| `observed` | your server's own report, or a platform API (Forge, Vercel, DigitalOcean) | the server reports PHP 8.5.12; Forge site runs PHP 8.4 |
| `pinned` | an explicit version file | `.nvmrc` says `22` |
| `ci` | a GitHub Actions setup step | `actions/setup-node` with `node-version: 20` |
| `declared-minimum` | a manifest constraint | `composer.json` requires `^8.2`, shown as 8.2 |

`declared-minimum` values get a small badge in the dashboard: the project allows that version but
might run a newer one. The deploy target follows the same idea, except that a manual override on
the repository page always wins.

### How precise a version is

A version is shown as precisely as its source states it, and never more precisely:
`.php-version` with `8.5.12` shows PHP 8.5.12, `FROM node:22-alpine` shows Node.js 22, Forge's
`php85` shows PHP 8.5, and `"php": "~8.3.1"` shows ≥ 8.3.1. A leading `v` is dropped. A
prerelease (`8.5.0RC1`, `8.5-rc`) is shown as its major.minor (8.5), and aliases such as
`lts/iron` or `latest` are skipped with a warning.

End-of-life checks, finding names and severities always use the major.minor release cycle
(8.5.12 → 8.5), so learning an exact version, or a patch upgrade, never opens, closes or renames a
finding, and the digest reports a runtime change only when the cycle changes. Two sources that
agree on the digits they both state (a `8.5.12` pin and a CI step on `8.5`) do not count as a
conflict. The chip's tooltip says where the version came from.

An observed version still wins over the repository files, but it does not throw away precision
the repository adds: when a platform reports only `8.5` and `.php-version` pins `8.5.12` (or a CI
step sets up `8.5.12`), the chip shows 8.5.12 as observed, with the tooltip "observed on Forge … ·
exact version pinned in .php-version". A [server report](#server-reports) beats everything, then
the [command Forge runs](#exact-versions-from-a-command-forge-runs), then the site's own response
headers, then a pin, then CI. A pin on another cycle (`8.4.2` while Forge runs 8.5) is ignored: the platform is what
runs.

## Repository files

Always on, no setup. Uses the GitHub App's Contents permission (or the token for URL-added repos).

- **Reads:** from the repository root only (plus the optional per-repo scan paths for a
  monorepo): the files in the [runtime table](#supported-runtimes) below, the lockfiles in the
  [dependency table](#dependencies-and-vulnerabilities), `.tool-versions`, `Dockerfile`,
  `wrangler.toml|json|jsonc`, `serverless.yml`, `cdk.json`, `samconfig.toml`, `template.yaml`,
  `.do/app.yaml`, `vercel.json`. Each scanned folder is listed once and only the files the listing
  shows are fetched (a few requests per repository); `.github/workflows` is listed only when
  `.github` exists. When a listing fails or has 1,000 entries (GitHub's cap), that folder falls
  back to asking for every known file. .NET project files (`*.csproj`, `*.fsproj`, `*.vbproj`)
  are taken from the repository root's listing only (at most 5, 200 KB each): a nested layout
  (`src/App/App.csproj`) is covered by `global.json`, the Dockerfile and CI instead. Scan paths
  do not read project files either.
  Nothing is cloned and nothing is executed; each file is parsed in memory and discarded.
- **Sets:** runtimes with their version and confidence, frameworks with the version from the
  lockfile, dependencies with versions (dev-only ones flagged), and the deploy target.
- **Runtime precedence, per runtime:** a server or platform observation (`observed`, see below),
  then a pin file (`pinned`), then a CI setup step (`ci`), then the minimum of a manifest
  constraint (`declared-minimum`, shown with a small `≥`). Two pins that disagree, or a pin and CI
  on another version, add a warning; the first pin in the table's order wins.
- **Deploy target, first match wins:** a `wrangler` config means Cloudflare; `serverless.yml`,
  `cdk.json`, `samconfig.toml` or an AWS SAM `template.yaml` mean AWS; `.do/app.yaml` means
  DigitalOcean; `vercel.json` means Vercel. Then a deploy hint from a workflow (below). A
  `Dockerfile` with nothing else means a container. Otherwise unknown.
- **Not read:** `yarn.lock`, `Pipfile.lock`, Maven and Gradle dependency trees, `deno.json`
  (it has no runtime version), nested packages outside the scan paths.

### Supported runtimes

Every runtime below gets a chip, an end-of-life check, the `lang:` filter and its own version key
(`python:3.12`). The version is shown as precisely as the file states it; the **cycle** is what
end-of-life, severity, finding keys and the digest's "runtime changed" key on. A file that names
no concrete version is skipped: a release channel (`stable`, `nightly`, `latest`, `lts/*`) in a
CI step or `rust-toolchain` file with a note, anything unreadable with a warning.

| Runtime | Pins (in precedence order), then CI, then declared minimum | endoflife.date product | Cycle |
| --- | --- | --- | --- |
| PHP | `.php-version`, `.tool-versions` `php`, `FROM php:`; `shivammathur/setup-php`; `composer.json` `require.php` | `php` | major.minor (`8.5`) |
| Node.js | `.nvmrc`, `.node-version`, `.tool-versions` `nodejs`, `FROM node:`; `actions/setup-node`; `package.json` `engines.node` | `nodejs` (cycles are majors) | major.minor (`22.11`), matched to the major |
| Go | `.tool-versions` `golang`, `FROM golang:`, `go.mod` `toolchain` / `go`; `actions/setup-go` | `go` | major.minor (`1.22`) |
| Bun | `.bun-version`, `.tool-versions` `bun`, `FROM oven/bun:`; `oven-sh/setup-bun`; `package.json` `engines.bun`, else `packageManager: "bun@…"` | `bun` | major (`1`) |
| Deno | `.dvmrc`, `.tool-versions` `deno`, `FROM denoland/deno:` (also `alpine-…` tags); `denoland/setup-deno` | `deno` (Deno 2 per minor, Deno 1 as `1`) | major.minor (`2.1`) |
| Python | `.python-version`, `runtime.txt` (`python-3.12.3`), `Pipfile` `python_full_version` (exact) or `python_version` (the cycle pipenv enforces), `.tool-versions` `python`, `FROM python:`; `actions/setup-python`; `pyproject.toml` `requires-python` (else Poetry's `python`, also `{ version = … }`) | `python` | major.minor (`3.12`) |
| Ruby | `.ruby-version` (`3.3.0`, `ruby-3.3.0`), `Gemfile` `ruby "3.3.0"`, `Gemfile.lock` `RUBY VERSION`, `.tool-versions` `ruby`, `FROM ruby:`; `ruby/setup-ruby`; `Gemfile` `ruby ">= 3.2"` / `"~> 3.2"` | `ruby` | major.minor (`3.3`) |
| Java | `.java-version`, `.sdkmanrc` (`java=21.0.2-tem`), `.tool-versions` `java` (`temurin-21.0.2`), `FROM eclipse-temurin:` / `openjdk:` / `amazoncorretto:`; `actions/setup-java`; `pom.xml` `maven.compiler.release` / `java.version` / `maven.compiler.source`, `build.gradle(.kts)` toolchain / `sourceCompatibility` | `eclipse-temurin` (there is no `java` product) | major (`21`); `1.8` reads as 8 |
| .NET | `global.json` `sdk.version`, `.tool-versions` `dotnet`, `FROM mcr.microsoft.com/dotnet/…:`; `actions/setup-dotnet`; the lowest `<TargetFramework(s)>` (`net8.0`, `netcoreapp3.1`) in root `*.csproj` / `Directory.Build.props` | `dotnet` (`8`, older `3.1`) | major.minor (`8.0`) |
| Rust | `rust-toolchain.toml` `channel`, `rust-toolchain`, `.tool-versions` `rust`, `FROM rust:`; `dtolnay/rust-toolchain@1.78.0` or its `toolchain` input, `actions-rust-lang/setup-rust-toolchain`; `Cargo.toml` `rust-version` (chip only) | `rust` | major.minor (`1.78`); judged by releases behind |

Things worth knowing:

- **Rust and Deno 2:** endoflife.date supports only the newest release of each, so every older
  release reads as end-of-life within weeks. Repodar does not raise an end-of-life finding for
  them; it counts **releases behind** the newest instead, as a "versions behind" finding
  (`major_behind:rust`: "Rust 1.78 is 21 releases behind (newest 1.99)"). Rust: 4 or more
  releases behind is medium, 8 or more high (about six and twelve months). Deno 2: 2 or more
  medium, 4 or more high. Deno 1 is a real end-of-life line and keeps its end-of-life finding.
  A `rust-version` in `Cargo.toml` is a minimum supported version that lags on purpose: it gets
  a chip and no finding; pin a toolchain to have it checked.
- **Java:** Repodar reads end-of-life dates from Eclipse Temurin (Adoptium's OpenJDK builds).
  Other vendors (Oracle, Corretto, Zulu) support some versions longer or shorter.
- **.NET:** `global.json` pins an SDK (`8.0.300`) while images and servers name a runtime
  (`8.0.6`); only a different major.minor counts as a conflict. `netstandard2.x` and .NET
  Framework targets (`net48`) are not runtime cycles and are skipped with a note.
- **Bun:** a `bun.lock` alone says which package manager installs packages, not that Bun runs the
  app, so it does not create a Bun runtime.
- **Limits of the readers:** TOML (`pyproject.toml`, `Pipfile`, `Cargo.toml`,
  `rust-toolchain.toml`, the Python and Rust lockfiles) goes through a small tolerant reader for
  just these keys; XML and Gradle scripts are read with patterns, never evaluated, so a version
  inherited from a parent POM, set in `gradle.properties` or a version catalog, or computed in
  code is not seen.

### Dependencies and vulnerabilities

| Ecosystem | Read from | Advisories |
| --- | --- | --- |
| Composer | `composer.lock` (`packages-dev` flagged dev) | Packagist security advisories |
| npm | `package-lock.json`, `pnpm-lock.yaml`, `bun.lock` (one, dev flagged) | OSV.dev |
| Go | `go.mod` | OSV.dev |
| PyPI | `poetry.lock` (`category = "dev"` from Poetry before 1.5 flagged dev), `uv.lock` (registry packages), `requirements.txt` (exact `==` pins only) | OSV.dev |
| RubyGems | `Gemfile.lock` `GEM` specs (Bundler records no groups, so nothing is dev) | OSV.dev |
| crates.io | `Cargo.lock` (crates.io packages only) | OSV.dev |

Not covered: Maven and Gradle dependencies (no lockfile to read), `Pipfile.lock`, NuGet, and
git or private-registry packages. Frameworks with end-of-life checks from these files: Django
(`django` in the Python lockfiles or `requirements.txt`), Rails (`rails`, else `railties`, in
`Gemfile.lock`) and Spring Boot (the `spring-boot-starter-parent` parent or the
`spring-boot-dependencies` BOM in `pom.xml`, the `org.springframework.boot` Gradle plugin).

## GitHub Actions workflows

Always on, no setup. Same permission as the repository files.

- **Reads:** up to 20 files in `.github/workflows/` (200 KB each).
- **Sets:** runtime versions with confidence `ci` from `actions/setup-node` (`node-version` or
  `node-version-file`), `shivammathur/setup-php` (`php-version`), `actions/setup-go`
  (`go-version`), and since step 16 `actions/setup-python`, `ruby/setup-ruby`,
  `actions/setup-java`, `actions/setup-dotnet`, `oven-sh/setup-bun`, `denoland/setup-deno`,
  `dtolnay/rust-toolchain` (the version may be the action's ref, `@1.78.0`) and
  `actions-rust-lang/setup-rust-toolchain`. A version matrix or a multi-line version list counts
  as its highest version, with a note; an input naming a file (`ruby-version: .ruby-version`) or a
  release channel (`toolchain: stable`, `bun-version: latest`) is skipped with a note, and a named
  file is read as a pin on its own.
- **Deploy hints:** `cloudflare/wrangler-action` means Cloudflare, `aws-actions/*` AWS,
  `digitalocean/action-doctl` DigitalOcean, `amondnet/vercel-action` or the `vercel` CLI Vercel,
  a Forge deploy-hook URL Forge, `appleboy/ssh-action` a server, `docker/build-push-action` a
  container, `flyctl` Fly. A hint ranks below the platform files above.

## Dockerfile base image

Always on. The OS under the `FROM` image (Debian, Alpine, Ubuntu, including tags such as
`php:8.3-fpm-bookworm`, which is Debian 12) is checked against endoflife.date like any runtime.
An unsupported release becomes an EOL finding named `os:<distro>`.

## Repository activity

Always on. GitHub's `pushed_at` marks a repository **stale** after 180 days without a push, and
archived repositories are tagged as such (`activity:stale`, `activity:archived` in the filter).

## Laravel Forge

- **Needs:** a Forge API token with `server:view` and `site:manage-commands`. Add
  `organization:view` so Repodar can walk every organization; without it, it falls back to
  listing sites directly. `site:manage-commands` is used for one thing only, the version command
  below; without it Repodar falls back to the site's headers.
- **Reads, once a day:** organizations, servers and sites. Forge allows 60 requests a minute;
  Repodar uses one request per organization, per server and per page of sites, plus the version
  command (below).
- **Sets:** deploy target Forge, the site's PHP version as `observed` (the server's default when
  the site has none; static and Node.js sites report no PHP), and the time of the last finished
  deployment.
- **Matching:** the site's repository (`owner/name`) against your repositories.

### Exact versions from a command Forge runs

Forge's API reports PHP as major.minor only (`php85` is PHP 8.5). So once a day Repodar asks Forge
to run one command on each site (`POST …/sites/{site}/commands`, in the site's directory, as the
site's user) and reads its output a minute later. The command is fixed text: the site's own PHP
binary, taken from Forge's PHP version for the site (`php8.5`; plain `php` when Forge names none),
then every other runtime it finds on the server's `PATH`:

```sh
php8.5 -r 'echo "php=".PHP_VERSION."\n";' 2>/dev/null; for c in node bun deno go python3 ruby java dotnet rustc; do command -v $c >/dev/null 2>&1 || continue; case $c in node) echo "node=$(node -v 2>/dev/null)";; … esac; done
```

It prints lines like `php=8.5.12`, `node=v22.11.0`, `python=Python 3.12.3`, and Repodar keeps
only the version numbers. What happens with them:

- **PHP** becomes the site's exact version ("observed on Forge site example.com · server web-1 ·
  exact version from a command Forge ran on the server"), when it agrees with the major.minor
  Forge reports for the site. It beats the header probe below.
- **Every other runtime** is stored with the site. It refines a runtime the repository already
  uses (its `.nvmrc` says `22`, the server runs `22.11.0`), and otherwise it is listed on the
  repository page under **Also on this server**, without a chip.
- **Every runtime gets EOL checks and chips** (see [supported runtimes](#supported-runtimes)):
  Python found on the server of a Django repository refines that repository's Python chip; Ruby
  found on the same server is listed under **Also on this server** unless the repository uses
  Ruby too.

Limits and fallbacks:

- At most once per site per day (a site asked in the last 20 hours is not asked again, so
  "Collect now" creates nothing new), at most 60 commands per poll, never-asked sites first, 15
  seconds for creating them, and one collect of a workspace at a time creates them. A site with a
  fresh [server report](#server-reports), or whose headers already gave an exact version, is not
  asked. The output is read in a separate run about 1, 3, 8 and 13 minutes after the create,
  until Forge reports the command finished (after the last read it is given up until the next
  day); at most two requests per command per attempt. Each runtime's output is cut to its first line.
- **Forge keeps a visible entry in each site's command history for every run**, so you will see
  one such entry per site per day.
- A non-zero exit, a timeout, output that is not a version, or a PHP that disagrees with Forge
  (another binary) is ignored: the version learned on an earlier day stays.
- The token lacks `site:manage-commands` (Forge answers 403): Repodar stops trying for 24 hours
  and the Forge card says "Add the `site:manage-commands` scope to your Forge token to read exact
  PHP versions". Saving a new token tries again at once. When it works, the card shows "Exact PHP
  versions: on · last run 2 h ago · 3 sites".

### Exact PHP version from the site's headers

Without the command (no scope, or not yet run), Repodar sends, after the Forge poll, one request
to each site's public domain, and never more than one request per site per day:

- `HEAD https://<site domain>/` with a 5 second timeout, no credentials or cookies, and the
  User-Agent `Repodar/1.0 (+https://repodar.dev/docs/sources)`. Redirects are not followed: the
  headers of the first response are read (PHP sends them on its own redirects too). A site asked
  in the last 20 hours is not asked again, so "Collect now" or saving the token again sends
  nothing new. At most 4 requests at a time, 60 per poll and 15 seconds in total; sites not
  reached are asked first next time. Sites named like `localhost`, `*.test`, `*.local`, `*.corp`,
  IP addresses (or names embedding one, such as `127.0.0.1.nip.io`), wildcards and names without a
  dot are skipped.
- It reads `X-Powered-By: PHP/8.5.12` (or a `Server` header carrying `PHP/x.y.z`). Only that
  version string is stored; nothing else from the response is kept or logged.
- The header counts only when it agrees with the major.minor Forge reports for that site. If it
  does not (a CDN, proxy or another app answered), it is ignored.
- No header: the site shows Forge's 8.5, exactly as before. A timeout or network error keeps the
  exact version learned on an earlier day, as long as Forge still reports the same major.minor.

The tooltip then reads "observed on Forge site example.com · server web-1 · exact version from
example.com response headers". To get exact versions, either set `expose_php = On` in the PHP
settings Forge manages for that site's PHP version (the FPM `php.ini` on the server's PHP page) so
PHP sends `X-Powered-By`, or pin the exact version in the repository's `.php-version`. Leaving
`expose_php` off is a reasonable security choice; Repodar then shows the major.minor.

## Server reports

For servers that are not on Forge (AWS, DigitalOcean droplets, bare metal), or to report from the
server itself, paste one line. The script finds your sites and the PHP that serves each one, and
needs no editing:

```sh
REPODAR_TOKEN=rdr_… sh -c "$(curl -fsS -m 10 https://repodar.dev/report.sh)" || true
```

- **Where:** as a daily cron job (or any scheduler), once per server: with no other context it
  reports every site on the machine in one request. The same line at the end of a deploy script
  reports just that site right after each deploy. Self-hosted, use your instance's
  `/report.sh`: it already reports to your `APP_URL`.
- **Needs:** a report token from **Settings > Sources > Other servers > Server reports**
  (Generate token). A workspace can hold up to 10 active tokens; give each a name. To keep the
  token out of scripts, store it once and drop `REPODAR_TOKEN=` from the line:

  ```sh
  mkdir -p ~/.repodar && printf '%s\n' rdr_… > ~/.repodar/token && chmod 600 ~/.repodar/token
  ```

- **Check first:** add `--dry-run` to print the JSON instead of sending it (`sh -c "$(curl …)"
  report --dry-run`; the word `report` fills the script's `$0`), and `REPODAR_DEBUG=1` to see
  each step and the answer on stderr. `--site example.com` reports that one site.
- **It never fails a deploy or a cron job:** every path ends with exit code 0, every request has a
  10 second limit (`curl -m 10`), and errors are printed only with `REPODAR_DEBUG=1`. Without a
  token it prints one line to stderr and stops.

### What the script reads and sends

It needs `sh`, `sed`, `head`, `tr` and curl 7.55 or newer (for `-H @-`). It reads, and nothing
else:

- `$FORGE_SITE_PATH` and `$FORGE_PHP` when a Forge deploy script runs it, else the current
  directory when it is `/home/<user>/<domain>/…`, else every site: each directory
  `/home/*/<name>` whose name contains a dot (not starting with one), plus the first
  `server_name` of every server block in `/etc/nginx/sites-enabled/*` (skipping `_`, `default`,
  `localhost` and wildcards); at most 50 sites;
- per site, the PHP that serves it: `$FORGE_PHP`, else the `fastcgi_pass
  unix:/var/run/php/phpX.Y-fpm.sock` (or Forge's `phpX.Y-fpm-<site>.sock`) line in
  `/etc/nginx/sites-available/<site>` or `sites-enabled/<site>` (or the file whose `server_name` named the site), else `php` on `PATH`; then
  `phpX.Y -r 'echo PHP_VERSION;'` (5 second timeout when `timeout` exists); and `node -v` for a
  site whose directory has a `package.json`;
- for the machine: `php`, `node`, `bun`, `deno`, `go`, `python3`, `ruby`, `java`, `dotnet` and
  `rustc` from `PATH` (their version output), and `hostname`. Node.js comes from `PATH` as is: the
  script does not switch versions with `nvm` or `fnm`, even when a site has an `.nvmrc`;
- the token from `$REPODAR_TOKEN`, else the first line of `~/.repodar/token`.

It sends one request, `POST /ingest/runtime` with the token as `Authorization: Bearer rdr_…`
(passed to curl on stdin, never on its command line):

```json
{ "host": "web-1", "agent": "report.sh/1",
  "runtimes": { "php": "8.5.12", "node": "22.11.0" },
  "sites": [ { "site": "archfeel.cz", "runtimes": { "php": "8.5.12" } },
             { "site": "shop.example.com", "runtimes": { "php": "8.4.11" } } ] }
```

Only values made of `A-Z a-z 0-9 . _ -` are ever put in it; a site directory or `server_name` with
anything else is dropped. Nothing from the network is ever run or interpreted: the answer is only
printed with `REPODAR_DEBUG=1`. On success it prints one line, such as
`repodar: reported 2 sites (php 8.5.12, 8.4.11) from web-1`.

The script is open source at
[apps/web/report/report.sh](https://github.com/gausejakub/repodar/blob/main/apps/web/report/report.sh)
and served verbatim at `/report.sh`; the only difference on a self-hosted instance is the default
URL line. `REPODAR_URL=https://repodar.example.com` points it elsewhere (plain `http://` only for
`localhost` or `127.0.0.1`, with an optional port).

SHA-256 of the script as served by https://repodar.dev/report.sh: `c4b2d1a72ed3d22ecb4d3233f0cb37d0af257ed5cc2ea1666a3f3139748aadab`.
Check it before running:

```sh
curl -fsS https://repodar.dev/report.sh | shasum -a 256
```

### What Repodar does with a report

- **Shape:** the batch above (≤ 50 sites, ≤ 16 KB), or the single report earlier snippets sent,
  `{ "site": "archfeel.cz", "host": "web-1", "repo": "owner/name", "runtimes": { "php": "8.5.12" } }`.
  The machine-level `runtimes` fill in for every site without its own value for that runtime. Each
  site needs `site` or `repo` and at least one runtime; a site that fails validation is skipped
  and listed in the answer, the others are kept. Runtime names: `php`, `node`, `bun`, `deno`,
  `go`, `python`, `ruby`, `java`, `dotnet`, `rust`. Versions are plain releases with one to three
  numbers; a leading `v` is dropped, prereleases, distribution suffixes and all-zero versions
  (`8.5.0RC1`, `8.3.6-1ubuntu1`, `0.0.0`) are refused. Site and host must be plain ASCII names.
- **Stores:** per site: the site, host, repository hint, runtime versions, the sending agent and
  the time received, as one entry per host + site + repository hint. Entries not refreshed for 30
  days are deleted, and a workspace keeps at most its 200 newest. Settings list the last report
  of each host (sites, agent, when).
- **Matching:** by `repo` first (your repository with exactly that `owner/name`, ignoring case),
  otherwise by `site` through your Forge sites: the Forge site with that name lends its repository
  match. A site that leads to two different repositories, or to none, stays unmatched.
- **Precedence:** a report is `observed` and beats every other source: report, Forge command,
  Forge header probe, pin, CI, Forge's major.minor. PHP from a report always applies to the matched
  repository; any other runtime refines a runtime the repository already uses and is otherwise
  listed under **Also on this server**, without a chip. Every runtime name gets EOL checks once the
  repository uses it. When several hosts report one repository,
  the **lowest** version counts, since that is the one that matters for end of life and security;
  the tooltip names every host ("reported by web-1, web-2 for archfeel.cz 2 h ago · lowest
  8.5.11"). If a fresh report disagrees with the major.minor Forge reports, the report wins and the
  repository page shows a note such as "Forge reports php 8.4, the server reports 8.5.12".
- **Freshness:** a report counts for 48 hours, then the repository falls back to the next source
  on its next scan; it stays listed on the repository page as "last report 3 d ago".
- **Rescans:** a report that changes the merged result for a repository queues a rescan of just
  that repository, under the same daily caps as a manual Rescan. Daily reports repeating the same
  versions cost nothing.

### Token handling and rotation

A token is `rdr_` followed by 32 random bytes (base64url). It is shown once, when generated, with
the lines already filled in; Repodar stores only its SHA-256 and cannot show it again. To rotate,
generate a new token, update the servers, then **Revoke** the old one: from then on it gets a 401,
and the script still exits 0. Settings show when each token was last used (updated at most once an
hour). Re-submitting the Generate form, for example by reloading the page that shows a new token,
creates nothing.

### Limits and answers

One request per run, 10 per token per minute and 120 per workspace per hour; the body may be at
most 16 KB. Answers are JSON: `202` with `matched` (site and repository), `unmatched` sites and,
when any were skipped, `rejected` (site and reason); a single report also gets `repo`. `400` for
a bad body, `401` for a missing, unknown or revoked token, `413` over 16 KB, `429` over a limit
(with `Retry-After`). Failed token checks also count against the sending IP, like bad webhook
signatures.

## Cloudflare

- **Needs:** a custom API token with Account Settings: Read, Workers Scripts: Read and Cloudflare
  Pages: Read. Add Workers CI: Read so Workers deployed by Workers Builds link to their repository.
- **Reads, once a day:** the accounts the token sees, their Workers scripts (and Workers Builds
  triggers) and Pages projects.
- **Sets:** deploy target Cloudflare and the last deploy (a Worker's last upload, a Pages
  project's newest successful deployment). `compatibility_date` and the `nodejs_compat` flag are
  shown as information only: Workers do not run a Node.js version.
- **Matching:** Pages projects by their connected GitHub repository, Workers by their Workers
  Builds repository connection. A Worker deployed with `wrangler deploy` from your laptop or CI has
  no repository link, so it appears as unmatched; map it by hand.

## Vercel

- **Needs:** a Vercel access token scoped to the teams Repodar should see. Vercel tokens cannot be
  limited to reading; Repodar only sends `GET` requests.
- **Reads, once a day:** your personal projects and those of every team the token sees, and their
  newest ready deployment.
- **Sets:** deploy target Vercel, the project's Node.js version (`22.x` becomes Node 22,
  `observed`, which beats `engines.node`) and the last deploy. The framework preset is shown as
  information only; the lockfile stays the truth for framework versions.
- **Matching:** the project's linked GitHub repository.

## DigitalOcean App Platform

- **Needs:** a personal access token with the custom scope `app:read` only.
- **Reads, once a day:** your App Platform apps and their specs.
- **Sets:** deploy target DigitalOcean and the active deployment's time. A runtime version counts
  as `observed` only when a component runs a prebuilt runtime image (`php:8.3-fpm`,
  `node:22-alpine`, `golang:1.23`); buildpacks name the runtime without a version and are shown as
  information. A component built from a `dockerfile_path` leaves the version to the repository's
  `Dockerfile`.
- **Matching:** the GitHub repository each component builds from. An app built from several
  repositories yields one entry per repository.

## GitHub Deployments

- **Needs:** the GitHub App permission **Deployments: Read-only**, accepted by each installation
  (see [self-host.md](/docs/self-host.md#8-optional-github-deployments)). No token: it uses the same
  access as the scan.
- **Reads, once a day:** the newest ten deployments per repository and the statuses of the newest
  deployment per environment, at most 400 requests per run per team. Repositories beyond that are
  read first on the next run.
- **Sets:** the last successful deployment per environment, shown as "deployed 2 d ago ·
  production". It is the "last deployed" value only when no platform above provides one. A
  deployment created by `vercel[bot]` also hints the deploy target Vercel when nothing else set one.
- **Matching:** none needed, it reads each repository directly.
- **Without the permission:** the source probes one repository per installation, gets a 403 and
  waits; settings say "Waiting for permission" and nothing else changes.

## GitHub token for URL-added repositories

Not a deploy source, but stored and checked the same way.

- **Needs:** a fine-grained personal access token with "Only select repositories" and Contents:
  Read-only plus Metadata: Read-only. Classic tokens (`ghp_…`) are refused: they carry
  account-wide access.
- **Used for:** repositories you added by URL (the ones the GitHub App cannot see, usually where
  you are a collaborator). Without it, Repodar reads them with your sign-in token.
- **Checks, once a day:** which URL-added repositories the token can reach (`source:github-pat`
  in the filter).

## How matching works

After every collect, each thing a provider reports (a Forge site, a Worker, a Vercel project, an
app) is matched to a repository in your workspace (server reports match on arrival, see
[above](#server-reports)):

1. A decision you made by hand (mapped to a repository, or ignored) sticks across collects, as
   long as that repository still exists in Repodar.
2. Otherwise the reported repository name is compared with your repositories' `owner/name`,
   ignoring case. A different branch than the repository's default still matches; the repository
   page shows a note.
3. Otherwise it is listed as **unmatched** in the source's card.

Only matched entries change a repository. They are merged into the next scan, so an observed PHP
version is the one checked against endoflife.date.

Sources are collected once a day before the daily scan. **Rescan** and **Scan all** collect them
first too, then scan, at most once every 5 minutes per workspace (a window shared with **Collect
now** and **Check again**); inside that window they scan straight away with what was last
collected. A Rescan reads GitHub Deployments only for the repository it rescans.

## Mapping by hand

Open **Settings > Sources**. Each source card lists what it found, with a **Repo in Repodar**
column. For an unmatched entry, pick the repository it deploys and save; pick **Ignore** for
things you do not want to track (a fork, a staging copy). Mappings change only Repodar, never the
provider. Removing a source deletes its token, everything it collected and your mappings, and the
affected repositories rescan with what their files declare.

To override the deploy target itself, open the repository page and choose a target under
**Deploy**. That override beats every source.
