Upstream¶
upstream: declares the upstreams a repo repackages or forks. StageFreight lists every
declared source on each run, reports what it found, and refuses to release a version whose
claim about upstream is false — before anything is built.
upstream:
sources:
core:
git: https://github.com/ansible/ansible
scheme: pep440
select:
exclude: ['^v1\.']
version:
track: core
since: v2.21.5
evidence:
image: # build id
probe: { package: ansible-core, manager: pip }
Sources¶
sources is an id → source map. Each source names exactly one kind:
| Kind | Value | Listed from | Default scheme |
|---|---|---|---|
git |
repository URL | git ls-remote (tags, with their commits) |
semver |
image |
image repository, no tag or digest | the OCI registry API | semver |
pypi |
project name | pypi.org (yanked releases skipped; upload times) | pep440 |
npm |
package name | registry.npmjs.org (deprecated versions skipped; publish times) | semver |
github-release |
owner/repo |
GitHub's published releases — not tags (drafts skipped, GitHub's prerelease flag honoured, publish times) | semver |
apk |
package name, plus suite: v3.22/main |
Alpine's APKINDEX (build times) | debian |
apt |
package name, plus suite: trixie/main |
Debian's Packages index | debian |
url |
a release page | every match of select.tags on the page (needs select.tags) |
semver |
Listing is anonymous: an upstream is someone else's public project. (github-release uses
GITHUB_TOKEN when the environment has one: GitHub's API allows 60 anonymous requests an
hour.) Where the kind publishes release times, the cooldown counts from them instead of from
first sight.
mirror_of (git, image) names the origin a source mirrors — for upstreams we cannot reach
directly and keep a mirror of. StageFreight lists the mirror, and the origin when it answers;
a mirror missing the origin's newest release raises mirror-behind. An unreachable origin is
why the mirror exists, so it is not an error.
lifecycle records what has happened upstream: active (default), dormant,
abandoned, gone, each but active with a required reason. A dormant or abandoned
upstream that cannot be listed is not reported unreachable. A gone one is not listed at all,
and cannot be tracked, pinned or synced — its history is ours now (version: { own: {} }).
select picks releases out of everything the source tags, in this order:
tags— a regex choosing candidates. The version is its named groupversion, else its first group, else the whole tag. Default: tags that start with a dotted version (1.2,v11.4,2.21.5),v-prefixed or not — commit-SHA tags are not candidates.exclude— regexes; a matching tag is never a release. Exclusion wins.rewrite—{from, to}regex replacements applied to the extracted version, in order ({from: '_', to: '.'}readsrelease_1_2_3as1.2.3).- The version is parsed with the source's
scheme.prereleases—falseby default — decides whether the scheme's prereleases count as releases.
A candidate whose version does not parse is ignored and reported ({upstream.unparsed}),
never sorted as a string.
Schemes¶
Versions are only ever compared within one scheme, so a source names the one its upstream uses (the kind's default, in the table above, when it does not):
scheme: |
Reads | Prereleases |
|---|---|---|
semver |
1.2.3, v1.2.3-rc.1; a missing minor or patch is zero (11.4 = 11.4.0) |
a hyphen suffix |
pep440 |
Python versions: 2.22.0b1, 1.0rc1, 1.0.post1, 1.0.dev3, 1!2.0 |
a/b/rc and .dev |
loose |
1–6 dotted numbers (0.6.7.1, 2026.7.0); a trailing letter is a later release (1.6.60a > 1.6.60) |
a hyphen suffix |
debian |
dpkg versions, [epoch:]upstream[-revision] (1:2.8.1-3+deb13u1, apk's 2.8.3-r4); our v3.7.5 names Debian's 3.7.5-1 |
~ (1.0~rc1) |
Our release tags are read with the claimed source's scheme, after the v prefix and any
revision suffix (-3, -r2) are removed.
Version — what our release tags claim¶
version: |
Our tag claims | A release is refused when |
|---|---|---|
track: <source> |
upstream's version | upstream never released that version |
continue: <source> |
our own lineage in upstream's numbering | upstream has released that version |
own: {} (default) |
our own lineage | never |
Our tag's version is read with the v prefix and any revision suffix (-3, -r2) removed,
using the claimed source's scheme, so v2.21.5-2 claims 2.21.5. since is the cutover: tags before it are not judged, so
changing mode never reinterprets old releases.
The check fails closed: if the claimed source cannot be listed, a release that needs it does not ship. Any other listing failure is a warning.
on_mismatch: report (with a required reason:) lets a false claim ship and raises
version-mismatch instead of refusing — for a lineage shared with upstream, say. The default
is refuse.
Evidence — the image must contain what the tag says¶
A version that upstream released is still a lie if the image ships something else (an
image-only fix tagged as the next upstream version). So track needs evidence for every
docker build, checked in publish on the exact bytes about to be distributed, before anything
reaches a registry. There are two kinds:
A probe reads the version out of the built image without running it:
| Probe | Reads |
|---|---|
label: <key> |
an OCI image label the image's own build sets (labels StageFreight stamps itself — org.opencontainers.image.version, …revision, … — are refused: they repeat the tag) |
file: /abs/path |
a file in the image (symlinks followed); match: picks the version |
package: <name> + manager: pip \| dpkg \| apk |
the package database — works on images with no shell |
match: is a regex whose version group (else first group) is the version; the default takes
the first dotted version. A package manager's own revision (-r3, Debian -1, epochs) is
removed before comparing. A probe that disagrees with the release tag refuses that image; a
probe that cannot read anything refuses it too — unproven is not proven. Per build,
on_mismatch: report + reason: tolerates an upstream whose metadata trails its own tag.
Ancestry (version.ancestry: true, git sources) proves a history fork instead: upstream's
commit for the claimed version must be an ancestor of the commit being released. It is the
right evidence where the build stamps its own version into the binary, which makes a probe
circular.
upstream:
sources:
zitadel: { git: https://github.com/zitadel/zitadel }
version: { track: zitadel, ancestry: true }
Validation names any docker build that has neither.
Following a base image¶
When track: names an image source, that upstream's version is also the authority for the
fork's base image: the Dockerfile FROM for the tracked image advances to upstream's latest GA
within the base's current major, regardless of dependency.max_update — shipping that
version is the fork's whole reason to exist, so the ceiling that scopes ordinary dependencies
does not cap it. A newer major is held for review (it surfaces as an available upgrade, never
applied on its own); adopt or hold a major line with the escape hatch track: <source>@<major>
(e.g. synapse@2). The FROM tag is rewritten in whatever style you pinned it — with a leading
v or without, both valid, the fork's choice, never rewritten into the other. A digest-pinned
FROM has its digest re-resolved from the tag upstream's registry actually serves (so a 1.2.3
pin re-pins to the real v1.2.3 manifest's digest), and the bump is held if that digest cannot be
resolved — never a new tag left on an old digest.
StageFreight also checks that the tag you pinned is one the registry actually serves, and warns
when it is not: a digest-pinned FROM still builds when its tag string resolves to nothing (the
digest carries the pull), but a dead tag is a reference other tools trip over, hidden behind the
digest. The check asks the registry directly and, when the pin is absent, names the form the
registry does serve for that version — it states what exists, it never prescribes a v or its
absence. (A momentary registry failure is not read as a dead pin.)
upstream:
sources:
synapse: { image: ghcr.io/element-hq/synapse }
version:
track: synapse
min_release_age: 3d # follow an upstream GA once it is this old; optional
Follow cooldown. version.min_release_age is how old an upstream GA must be before the fork
follows it onto the base, aged from the release's real publish time — the tag's image
creation time read from its own registry — so a tag already weeks old is adopted at once rather
than waiting from when StageFreight first saw it. An image that zeroes that field falls back to
the first-sighting clock in .stagefreight/upstream.lock. It governs only the tracked base,
so a fork can chase or hold upstream without touching its other dependencies' cooldown; unset, it
inherits dependency.min_release_age; 0 follows the instant upstream publishes.
Pins — moving with upstream¶
A source's pins are the places its version is pinned. When upstream releases, the
dependency update moves them — through the same engine, commit and merge-request handling as
every other dependency, honouring dependency.max_update and dependency.min_release_age:
upstream:
sources:
core:
git: https://github.com/ansible/ansible
scheme: pep440
pins:
- { file: Dockerfile, arg: ANSIBLE_CORE_VERSION } # ARG or ENV
- { file: versions.yml, path: ansible.core } # YAML or JSON dotted path
- { file: apps.txt, marker: true } # the line after the marker comment
| Pin | Where the version is |
|---|---|
arg: NAME |
a Dockerfile ARG or ENV declaration |
path: a.b.0.c |
a scalar in a YAML or JSON file (map keys, sequence indexes) |
marker: true |
the line after a comment containing stagefreight: upstream=<source id>; match: picks the version (default: the first dotted version) |
- One source, one version. Every pin of a source must hold the same version. If they disagree, none of them moves and the run says which pin holds what.
- Only the value changes. Each pin is located to its exact line and column, and the edit
replaces that value — quotes, comments and a
vprefix stay as written. - A declared pin wins. A dependency StageFreight would otherwise discover on the same line is left to the pin, so one value never has two writers.
- Cooldown. With
dependency.min_release_age, a release is adopted only once it has been known for that long. Git tags carry no release time, so StageFreight records when it first saw each newer version in.stagefreight/upstream.lock(committed, deterministic) and counts from there; the bump that adopts a version also clears it from the lock. Without a cooldown there is no lock.
Sync — keeping a fork on top of upstream¶
sync keeps a history fork current: on the default branch, StageFreight joins upstream's
newest release (or a branch head) into our branch and pushes the result, which starts an
ordinary pipeline on it.
upstream:
sources:
zitadel: { git: https://github.com/zitadel/zitadel }
version: { track: zitadel, ancestry: true }
sync:
from: zitadel
onto: release # default; or { branch: main }
strategy: rebase # default; merge | mirror
shadow: upstream # optional: a branch kept at upstream's synced commit
on_conflict: mr # default; fail | notify
strategy: |
Our branch afterwards |
|---|---|
rebase |
our commits replayed on top of upstream's release — rewritten, so the forge must let StageFreight force-push the branch, and every outbound mirror must follow a rewrite (repos.<id>.sync.branches.force: true; validation refuses a mirror that would silently stop) |
merge |
a merge commit joining upstream's release; nothing rewritten. The merge commit carries Upstream-Rev: <sha> |
mirror |
exactly upstream's branch (fast-forward only); a branch that has diverged is kept and reported, never overwritten |
How a sync behaves:
- Line-level merging, without guessing. Each change is a three-way merge: changes to different lines of a file combine; changes that overlap (or touch) are a conflict unless both sides made the identical change. A patch upstream has already taken is dropped from a rebase. Replaying the same history onto the same release always produces the same commits.
- Conflicts change nothing.
on_conflict: mrpushes upstream's commit tostagefreight/sync/<source>/<release>and opens a merge request into our branch listing the conflicting paths;failfails the run;notifyonly signals. All three raisesync-conflict. - Never over someone else's push. Our branch is pushed only if it still points at the commit the run started from; otherwise the next run syncs again.
- A refused force push becomes a merge request carrying the rebased branch, with what to do (allow StageFreight to force-push, or replace the branch with it).
- A rebase needs straight history. A branch with merge commits is reported; use
strategy: merge. - The dependency update waits. When a sync pushes, that run leaves the dependency update to the new commit's pipeline, so two writers never push onto a branch just rewritten.
Per-file rules¶
By default every file is merged line by line. files decides, per path, what happens when
upstream changes a file — for a file we deleted and upstream keeps editing, a file that is
ours by design, or one a human must always see:
upstream:
sync:
from: nut
files: # top to bottom; the last match wins
- { match: "**", take: merge } # the default, shown for clarity
- { match: "README.adoc", take: fork } # ours — our deletion stands
- { match: "docs/**", take: upstream } # upstream's; our edits are dropped
- { match: "drivers/usb*", take: review } # a human sees every upstream change
take: |
When upstream changes a matching file |
|---|---|
merge (default) |
combined line by line with ours; overlapping edits are a conflict |
fork |
the fork's version stays — including a deletion — and the sync reports it under {sync.held}, every time, so a held file never silently misses an upstream fix |
upstream |
upstream's version wins; our edits to it are dropped and reported under {sync.overridden} |
review |
the sync becomes a conflict (an MR under on_conflict: mr), even when it would merge cleanly |
- Fork and upstream, never ours and theirs. The names say which side wins in a rebase and in a merge alike (git swaps "ours" during a rebase).
- No markers needed. Our customization is exactly the difference between the common base and our branch; the rules only decide what happens where upstream changed the same file.
- Holds are attributed. In a merge they live in the merge commit. In a rebase, our replayed commits keep only their own changes, and the fork's version of held files our commits never touched is restored in one commit of its own on top ("Keep the fork's version of …", listing them).
- StageFreight's own files always keep the fork's version:
.stagefreight.yml,.stagefreight/, and every rendered CI file — upstream's own CI never merges into ours. - Rules are plain config, so a fleet-wide rule set can come from governance, with each repo adding its exceptions after it. A mirror is an exact copy; rules apply to rebase and merge.
With version.track on the same source and release: auto, the synced commit is released as
the newest upstream release it contains — no pins needed. version.ancestry: true then proves
that release on the tag pipeline.
Release — cutting the tag¶
Under track, release: auto (the default) cuts the release itself: after a successful run on
the default branch, when the source's pins name a version upstream has released and no tag of
ours names it yet (or, for a synced fork, when the built commit contains an upstream release
we have not tagged), StageFreight tags the commit it just built with that version (keeping our
v convention) and pushes the tag. The release pipeline then checks every guard and evidence
probe like any hand-cut release. release: manual leaves tagging to a person
(stagefreight tag). A rebuild of an already-released version stays an in-place rebuild — no
new tag. The tag spawns a release pipeline only if it matches a declared git.tags release
pattern.
Image identity — audit by digest, not tag¶
A fork's image version tag rolls. The tag (1.164.1) names the line, not one build: when a
dependency patches a vulnerability, the rebuilt image re-pushes that same tag and the release is
re-cut in place, so the latest upstream version plus the fix always lives at the version
downstream already asked for. There is no 1.164.1-2 revision tag — semver reads a hyphen suffix
as a prerelease below 1.164.1, Renovate mis-tracks it, and a fourth version segment drifts
identity. The rebuild counter lives in a label, off the tag.
So the audit identity of a specific build is the digest, never the tag string:
- The digest (
@sha256:…) is immutable and content-addressed — the one reference that cannot be re-pointed. - It carries, as OCI labels,
org.opencontainers.image.revision(the exact source commit),org.stagefreight.plan.sha256(the content-aware build fingerprint), andorg.stagefreight.build.number(which rebuild of the line this is — a counter that advances only when the content changes, so a rebuild that changed nothing manufactures no churn). - When the signing profile attests (see signing), the digest also carries a cosign attestation — SLSA provenance, the SBOM, and the vulnerability scan — bound to those exact bytes.
The contract, for downstream observers: pin by digest and verify the digest's attestation, not the tag. The tag is a maintained pointer to the current good build of the line; the digest and its attestation are the forensic record of one build. Every re-cut release records the build number beside each digest in its body, so the moving tag always resolves back to a build you can name and verify.
Signals¶
What the watcher finds is raised as signals, which notifications select with
when.signals:
| Signal | Raised when |
|---|---|
updates-available |
the tracked source released a version newer than our newest |
version-mismatch |
one of our tags makes a false claim (track: never released; continue: already released), or a tolerated (report) guard or probe mismatch shipped |
upstream-unreachable |
a source could not be listed |
sync-conflict |
a sync could not land: upstream's changes overlap ours, a mirror diverged, or the push was refused |
mirror-behind |
a mirror_of source lacks a release its origin has made |
notifications:
upstream:
provider: ntfy
url: https://ntfy.example.com/forks
body: "{project}: upstream {upstream.latest}, ours {upstream.ours}"
when:
signals: [updates-available, version-mismatch]
upstream¶
Upstream declares the upstreams a repo repackages or forks: each source is watched for new releases, and a release whose version claim about one is false is refused.
upstream:
sources: # Sources are the watched upstreams, keyed by id. The id is how the rest of the config refers to a…
<id>: # entry key = the unique id
git: <string> # Git is an upstream git repository URL, e.g. https://github.com/ansible/ansible. Its tags are listed…
image: <string> # Image is an upstream container image repository without a tag, e.g. ghcr.io/zitadel/zitadel. Its…
pypi: <string> # PyPI is a Python project on pypi.org (yanked releases are not releases). Scheme pep440.
npm: <string> # Npm is a package on registry.npmjs.org (deprecated versions are not releases).
github-release: <string> # GitHubRelease is a GitHub repository (owner/repo) whose published releases — not its tags — are…
apk: <string> # Apk is an Alpine package, read from the suite's APKINDEX. Needs suite. Scheme debian.
apt: <string> # Apt is a Debian package, read from the suite's Packages index. Needs suite. Scheme debian.
url: <string> # URL is a release page; every match of select.tags on it is a candidate (needs select.tags), for…
suite: <string> # Suite (apk, apt) is <release>/<repository>: v3.22/main for Alpine, trixie/main for Debian.
mirror_of: <string> # MirrorOf (git, image) is the origin this source mirrors. StageFreight lists the mirror (ours…
lifecycle: <string> # Lifecycle is the upstream's state: active (default), dormant, abandoned, gone. Anything but active…
reason: <string> # Reason says why an upstream is not active.
select: # Select picks the upstream's releases out of everything it tags.
tags: <string> # Tags is a regex choosing candidate tags. The version is the named group "version", else the first…
exclude: [<string>] # Exclude lists regexes; a tag matching any of them is never a release. Exclusion wins over tags.
prereleases: false # Prereleases admits upstream prereleases (as the scheme defines them) as releases. Default false.
rewrite: # Rewrite edits the extracted version before it is parsed, in order: each from is a regex replaced by…
- from: <string> # required
to: <string> # empty deletes the match
scheme: <string> # Scheme is how this upstream's versions are read and ordered. Default semver. semver: 1.2.3… · one of: debian, loose, pep440, semver
pins: # Pins are the places this upstream's version is pinned. When upstream releases, the dependency…
- file: <string> # File is the repo-relative file holding the pin. · required
arg: <string> # Arg is a Dockerfile ARG or ENV name whose value is the version.
path: <string> # Path is a dotted path to a scalar in a YAML or JSON file (ansible.core, apps.0.tag).
marker: false # Marker pins the line after a comment containing "stagefreight: upstream=<source id>", for any other…
match: <string> # Match (marker only) is a regex whose version group (else first group) is the pinned version.…
version: # Version says what this repo's release tags claim about an upstream.
track: <string> # Track names the source whose version our release IS. A release naming a version that source never…
continue: <string> # Continue names the source whose numbering our own lineage continues (a dormant or diverged…
own: <value> # Own declares our own version lineage: upstreams are watched, never enforced. The default when…
min_release_age: <value> # MinReleaseAge is the FOLLOW cooldown for a tracked base image: how old an upstream GA must be…
since: <string> # Since is the cutover tag: only release tags at or after this version are judged, so a mode change…
ancestry: false # Ancestry (track, git source) requires upstream's commit for the claimed version to be an ancestor…
on_mismatch: <string> # OnMismatch is what happens when a release's claim is false (track: upstream never released it or…
reason: <string> # Reason says why a false claim is tolerated (required with on_mismatch: report).
release: <string> # Release decides who cuts a track release when the pins name a new upstream version: auto (default…
sync: # Sync keeps this repository's history on top of a git upstream: rebased or merged onto its newest…
from: <string> # From names the git source to sync from.
onto: # Onto is what to sync onto: release (default — the newest release the source's select: keeps) or…
branch: <string>
strategy: <string> # Strategy is how upstream's history joins ours: rebase (default): our commits are replayed on top of…
shadow: <string> # Shadow, when set, is a branch of ours kept at upstream's synced commit — a plain copy of upstream…
on_conflict: <string> # OnConflict is what happens when upstream's changes overlap ours: mr (default): nothing is…
files: # Files decides, per path, what happens when upstream changes a file. Rules are read top to bottom…
- match: <string> # Match is a path glob (** spans directories), e.g. README.adoc, docs/**, **/*.md. · required
take: <string> # Take is what wins when upstream changes a matching file: merge (default): combine line by line… · required
evidence: # Evidence proves, per build, that the built artifact contains the version a release claims. Keyed by…
<id>: # entry key = the unique id
probe: # Probe reads the version the built image contains. · required
label: <string> # Label is an OCI image label whose value is the version — one the image's own build sets from what…
file: <string> # File is an absolute path in the image filesystem holding the version.
package: <string> # Package is an installed package whose version is read from the package database named by manager.
manager: <string> # Manager is the package database package reads: pip (site-packages dist-info), dpkg…
match: <string> # Match is a regex applied to the label value or file content; the version is its named group…
on_mismatch: <string> # OnMismatch is what happens when the probe disagrees with the claimed version: refuse (default) does…
reason: <string> # Reason says why a mismatch is tolerated (required with on_mismatch: report), e.g. an upstream whose…