- PHP 98.6%
- Dockerfile 1%
- Shell 0.4%
|
All checks were successful
ci/woodpecker/push/build Pipeline was successful
- Move the URL-or-owner/name parsing duplicated across two commands into GitHubRepo::fromUrlOrFullName(), and let package:add accept both forms like its siblings instead of URLs only. - Simplify HomeController's list building back to the arrow-fn idiom used elsewhere. - Give both functional test classes the same tearDown so the shared test storage dir can't leak packages between tests. - Drop the empty Doctrine scaffold dirs (src/Entity, src/Repository — no ORM here, Doctrine only backs the queue) and the stale Flex placeholder in src/Controller. - Track .editorconfig (it defines the project style) and gitignore build.log. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|---|---|---|
| .woodpecker | ||
| bin | ||
| config | ||
| kubernetes | ||
| public | ||
| secrets | ||
| src | ||
| tests | ||
| .dockerignore | ||
| .editorconfig | ||
| .env | ||
| .env.dev | ||
| .env.docker.example | ||
| .env.example | ||
| .env.test | ||
| .gitignore | ||
| compose.yaml | ||
| composer.json | ||
| composer.lock | ||
| Dockerfile | ||
| phpunit.dist.xml | ||
| README.md | ||
| symfony.lock | ||
pak
A small, webhook-driven private Composer registry for GitHub-hosted packages — the core of satis, minus the full-rebuild model. When a tag or branch is created (or deleted) on a configured repository, a GitHub webhook queues a refresh of that one package, so new releases are installable seconds after tagging.
How it works
GitHub App webhook (create/push/delete) composer client
│ POST /webhook/github │ GET /packages.json, /p2/… (basic auth)
▼ ▼
app container ── verify HMAC ──► queue (messenger, sqlite on /data)
│
worker container
▼
index repo via GitHub API (composer/composer GitHubDriver, no cloning)
▼
/data/repo/p2/vendor/name{,~dev}.json + packages.json (atomic writes)
- Metadata only — dist entries point at GitHub's zipball API. Clients fetch archives straight from GitHub with their own credentials, so the registry stores no archives and needs no purging.
- Composer 2 format (
metadata-url) — one metadata file per package, which is what makes cheap partial updates possible. - The repository list lives in a satis-like JSON file (
/data/registry.jsonin Docker), managed with console commands. GET /is a tiny human-only page listing indexed package names — a quick way to check what's in the registry in a browser. Composer clients never hit it; they read/packages.jsondirectly.
Quick start
cp .env.docker.example .env.docker # fill in the values (see below)
docker compose up -d --build
# add a repository and index it
docker compose exec app php bin/console package:add https://github.com/acme/widget
.env.docker values:
| Variable | Meaning |
|---|---|
APP_SECRET |
any long random string |
GITHUB_APP_ID |
your GitHub App's ID; leave empty to run unauthenticated (public repos only) |
GITHUB_APP_PRIVATE_KEY_PATH |
keep the default and put the PEM at ./secrets/github-app.pem |
GITHUB_WEBHOOK_SECRET |
the webhook secret configured on the GitHub App |
REGISTRY_USER / REGISTRY_PASSWORD |
credentials composer clients use (cleartext) |
GitHub App setup
Only needed for private repositories, or to raise GitHub's API rate limit
above the ~60/hour anonymous ceiling. Skip this whole section and leave
GITHUB_APP_ID empty to index public repositories only.
Create a GitHub App (org or personal account → Settings → Developer settings → GitHub Apps → New GitHub App):
- Webhook URL:
https://your-host/webhook/github, with a generated secret (openssl rand -hex 32) →GITHUB_WEBHOOK_SECRET - Permissions: Repository contents — Read-only, Metadata — Read-only
- Subscribe to events:
create,push,delete - Identifying and authorizing users (Callback URL, "Request user authorization (OAuth) during installation"): leave this whole section blank/unchecked. pak has no user login of any kind — the app talks to GitHub server-to-server only (a JWT signed with its private key, exchanged for a short-lived installation token), never through a browser, so there is nothing for a callback URL to do.
After creating the app: copy its App ID — the plain number at the top of
the settings page — into GITHUB_APP_ID. Don't use the Client ID shown
right next to it (looks like Iv1.xxxxxxxxxxxxxxxx); that's for the OAuth
user-login flow pak doesn't use, and using it here fails auth with a flat
401 on every request. Then, further down the same page under "Private
keys," click "Generate a private key" — GitHub creates the RSA keypair
itself (keeping the public half; pak never sees or needs it) and downloads
the private half as a .pem file, this once. Move that file to wherever
GITHUB_APP_PRIVATE_KEY_PATH points (./secrets/github-app.pem by
default, chmod 600 it) — no format conversion needed. Finally install
the app on the repositories or the whole org you want indexed.
You don't need to install it on every repository you plan to add: if
package:add/package:update hits a repository the app has no installation
covering, pak automatically retries unauthenticated instead of failing —
which only ever succeeds for genuinely public repos, since a private one
still 404s unauthenticated. So a single instance can mix private
repositories (via the app) with arbitrary public ones.
Because the webhook is app-level, every installed repository reports tag and branch activity automatically — no per-repo webhook setup.
The webhook endpoint is authenticated by GitHub's HMAC signature; everything
else — including the plain package list at / — sits behind HTTP basic auth
(/healthz excepted).
CI
.woodpecker/build.yaml runs the test suite and then builds and pushes the
image (Woodpecker CI, buildx plugin with registry layer caching). Set the
registry, registry_username and registry_password secrets and mark the
repo as trusted; pushes to main publish :latest, git tags vX.Y.Z
publish semver tags.
Kubernetes
Manifests (Deployment, Service, PVC, Ingress, kustomization) live in
kubernetes/ together with deployment instructions.
Consuming the registry
In a project's composer.json:
{
"repositories": [
{"type": "composer", "url": "https://packages.example.org"}
]
}
and in auth.json (or composer config --global):
{
"http-basic": {
"packages.example.org": {"username": "composer", "password": "…"}
}
}
For private repositories, clients also need a GitHub token
(composer config --global github-oauth.github.com <token>) because dist
archives are downloaded from GitHub directly.
Commands
| Command | Purpose |
|---|---|
package:add <url or owner/name> |
add a repository, index it immediately |
package:remove <url or owner/name> |
remove a repository and its published metadata |
package:update <url or owner/name> |
manual reindex (same code path as a webhook) |
registry:build |
full rebuild of everything (initial population / recovery) |
Run them with docker compose exec app php bin/console ….
The app container also runs registry:build itself on every start (see
bin/docker-entrypoint), before it starts serving traffic — so a fresh
volume, a redeploy, or config/registry.json edited by hand while the
container was stopped all end up fully reconciled without a manual step. A
failed rebuild at startup doesn't block boot; it logs a warning and serves
whatever was already on disk.
Development
No local PHP needed; the composer image (PHP 8.5) works for everything:
alias dphp='docker run --rm -u $(id -u) -e COMPOSER_HOME=/tmp/ch -v $PWD:/app -w /app composer:2'
dphp composer install
dphp php vendor/bin/phpunit
Locally (outside Docker) the repository list is config/registry.json and
generated metadata lands in var/data/repo/ — see the pak registry block in
.env.
Not implemented (by design)
Satis features intentionally dropped: dist archive mirroring/purge,
version-constraint cherry-picking, require-dependencies resolution, and
Packagist proxying. / is a minimal, human-only package list — not a
searchable or styled Satis-style index.