skip to content

How does Packagist turn a Git repository's tags and branches into Composer versions such as 1.2.0, 2.x-dev and dev-main?

level: middleimportance: should knowfreq 40%

answer

  1. tags are releases, branches are dev
  2. leading v ignored when comparing
  3. numeric branch gets .x-dev
  4. other branches get dev- prefix
  5. branch-alias under extra

basics

~20 s

Each valid tag becomes a release version, compared without its leading v, with any -beta or -RC suffix setting stability. Each branch becomes a dev version: 2.x becomes 2.x-dev, other names become dev-name, and extra.branch-alias can map dev-main to 1.0.x-dev.

solid answer

~40 s

Packagist reads a repository the way Composer's `vcs` repository does. **Tags** become releases: `v1.2.0` keeps its name but is normalised to `1.2.0` for comparison, a suffix like `-beta2` or `-RC1` sets the stability, and a tag that does not parse as a version, carries a `dev` suffix, has no `composer.json`, or disagrees with a hard-coded `version` key is skipped. **Branches** become development versions with stability `dev`: a version-like branch such as `2.x` becomes `2.x-dev`, and any other branch such as `main` or `feature-x` becomes `dev-main` or `dev-feature-x`. Because `dev-main` matches no numeric constraint, maintainers add `"extra": {"branch-alias": {"dev-main": "1.0.x-dev"}}` on `main`, so a constraint like `1.0.*` can install the latest development code.

code

bash · 9 lines
bash
git tag
# v1.0.0      -> 1.0.0
# v1.1.0-RC1  -> 1.1.0-RC1 (RC)
# latest      -> skipped: invalid tag name

git branch
# main        -> dev-main
# 1.x         -> 1.x-dev
# fix/leap-year -> dev-fix/leap-year

go deeper

for a junior

Know that tags become release versions and branches become dev versions, and that dev-main means the main branch.

for a middle

Explain the naming rules: v stripped, stability from suffix, 2.x-dev for version-like branches, dev- prefix for others, and why a hard-coded version key hides tags.

for a senior

Use branch-alias correctly, keep release branches version-like, and diagnose skipped tags from Composer's very verbose output instead of guessing.

for a principal

Set a release policy for a package family: tag format, supported branches, alias updates at each new major line, and never moving published tags.

## One source of truth: the VCS Composer's schema docs say it directly: **Packagist uses VCS repositories**. It does not accept uploaded archives. It reads a Git (or Mercurial, Subversion, Fossil) repository and derives every version from **tags** and **branches**, the same way Composer's own `vcs` repository type does. The package **name** is read from `composer.json` on the **default branch**. Each version's metadata (`require`, `autoload`, `license` and so on) is read from `composer.json` **at that tag or branch**. ## Tags become releases For every tag, the crawler: 1. strips a `release-` prefix, and normalises the rest so a leading `v` is ignored: tag `v1.2.0` keeps its name as the displayed version but compares as `1.2.0`; 2. parses the rest as a version; a tag that is not a valid version (`latest`, `deploy-2026-09`) is skipped; 3. reads the stability from the suffix: `-alpha`, `-beta` and `-RC` give those stabilities, and no suffix means `stable`; 4. skips the tag if it has a `dev` suffix or prefix, because a tag must not claim to be a moving target; 5. skips it if there is no `composer.json` at that commit; 6. skips it if the `composer.json` carries a `version` key that differs from the tag; 7. skips it if another tag already resolves to the same normalised version. `1.2` and `1.2.0` both normalise to `1.2.0.0`, so only one of them is imported. Every skip is silent for consumers. Composer prints `Skipped tag ...` with the reason only in very verbose (`-vvv`) output, which is why the best debugging step for a missing release is to read the repository with Composer in that mode. ## Branches become dev versions | Branch | Version | Stability | |---|---|---| | `main` | `dev-main` | dev | | `feature/rfc-parsing` | `dev-feature/rfc-parsing` | dev | | `2.x` | `2.x-dev` | dev | | `v1` | `v1.x-dev` | dev | | `1.4` | `1.4.x-dev` | dev | Two naming rules produce this. A branch whose name looks like a version gets a `-dev` **suffix** and becomes comparable with numeric constraints. Any other branch gets a `dev-` **prefix** and is comparable only with itself. Consumers can require a branch directly (`"mira/date-phrases": "dev-main"`), though `dev` stability must be allowed by their stability settings. ## Branch aliases `dev-main` never satisfies `^1.0` or `1.0.*`, so a project that needs unreleased fixes on `main` cannot use them alongside other packages that require `1.0.*`. The maintainer fixes this with a **branch alias**, committed on the branch it describes: ```json { "extra": { "branch-alias": { "dev-main": "1.0.x-dev" } } } ``` Now `dev-main` is also offered as `1.0.x-dev`, and a constraint such as `1.0.*`, with dev stability allowed, can install it. The alias must point to a comparable `.x-dev` version, and it must be updated when `main` moves on to the next line (for example to `2.0.x-dev` after `1.x` is branched off). ## Metadata is read per reference Each version carries the `composer.json` **from its own commit**: - Changing `require` on `main` changes `dev-main` as soon as Packagist re-reads the repository, but leaves every tagged version exactly as it was. - A requirement dropped on `main` still applies to consumers of the last tag until the next release. - A broken `composer.json` on a branch only breaks that branch's dev version; a broken one at a tag means the tag is skipped. This is why a release process usually runs `composer validate` on the commit that is about to be tagged, not on whatever branch happens to be checked out. ## Consequences for maintainers - **Tag a parseable version.** `v1.2.0` and `1.2.0` both work; `1.2.0-final` does not parse, and the tag is dropped. - **Do not hard-code `version`.** It makes every non-matching tag disappear. - **Keep `composer.json` correct before tagging.** The metadata at the tag is what every consumer resolves against. - **Keep release branches version-like** (`1.x`, `2.x`) so consumers can follow a line with `2.x-dev` if they must.

  • A consumer requires dev-main of your library, but another dependency requires ^1.0 of it. How do you let both work?
    Commit a branch alias on `main`: `"extra": {"branch-alias": {"dev-main": "1.0.x-dev"}}`. Packagist then offers `dev-main` as `1.0.x-dev` too, which can satisfy constraints on the 1.0 line when dev stability is allowed. A consumer without access to your repository would use an inline alias in their own `require` instead.
  • Why does Composer skip a tag that has a -dev suffix?
    A tag names one fixed commit, so it is a release. A `dev` marker would make it indistinguishable from a branch version, and branches move. Composer's repository reader rejects such tags with 'tags can not use dev prefixes or suffixes', and the version is never offered.

saying these in an interview costs you the question

  • A branch named main is offered as version main-dev.
  • Tags without a leading v are ignored by Packagist.
  • Packagist reads the package name from the latest tag.
  • dev-main satisfies ^1.0 once 1.0.0 is tagged.
  • Two tags 1.2 and 1.2.0 become two separate versions.