Skip to main content

Open-source release process

This document describes how we release c2pa-rs and its related crates, and the branching model that supports it. It applies to the 0.x (pre-1.0) phase; we will revisit it as we approach a 1.0 stability commitment.

Goals

We have two main goals:

  • Move fast on pre-1.0 refactors. While we're below 1.0, we may make larger refactors and breaking API changes.
  • Be stable and predictable as much as possible for users (including ourselves and the other language bindings that build on this crate): a steady stream of features, plus breaking changes that arrive on a pre-determined known schedule

Core principle: split by breaking vs. non-breaking

What matters to someone depending on this crate isn't whether a change is "big": it's whether it breaks their build. An additive feature costs them nothing; a changed or removed API forces a migration with potential fixes and updates to their build system. So we govern those two kinds of change on two different tracks, which map directly onto pre-1.0 Cargo semantics:

Change kindVersion slotCargo treats it asTrack
Additive / non-breaking0.x.y (bump y)compatibleTrack 1: fast, on the current train
Breaking0.x.0 (bump x)incompatibleTrack 2: scheduled "train"

Most changes never wait for the train: anything additive ships fairly quickly by being backported to the stable release train; only breaking changes are batched and scheduled.

Branching model

BranchRolePublished to crates.io?
mainAlways green but API-unstable ("nightly-like"). It must always compile and pass tests, but its public API is not guaranteed stable: it may contain unstabilized or feature-gated work.No
stableTracks the most-recent crates.io release and is the currently-active release line. Additive (0.x.y) releases, and the promoted breaking (0.x.0) release, are published from here.Yes
v0.x (e.g. v0.89)A long-lived branch for a retired release line, snapshotted from stable when that line is retired. A potential target if a security or other critical bug fix is made to a retired line.Yes (rare backports)
0.(x+1).0-rc (release-candidate branch)A transient breaking candidate, cut from main, that bakes before promotion. Its name ends in -rc so it is validated by CI but never matches a crates.io publish trigger. Individual builds cut from it (-rc.1, -rc.2, …) are tagged and get prerelease GitHub releases with binaries, but are never published to crates.io.crates.io: No; GitHub-release binaries: yes

The branch-name conventions are also the crates.io publish guard: the publish workflow only ever runs on stable and v0.*, and the -rc-named candidate branch matches neither, so a candidate can never be published to crates.io by construction. (Its build tags carry the -rc. prerelease marker and drive binary-only GitHub releases; see RC builds.)

Two rules keep this coherent:

  • Upstream-first. Every change lands on main first. Release-line branches only ever receive changes (via cherry-pick); nothing originates on them. This guarantees main is always the superset and that nothing is lost across a major bump. Two mechanisms enforce it: a proactive upstream-first check on every PR to a release branch, and a scheduled reconciliation check as a backstop.
  • main stays releasable. Destabilizing work happens on feature branches off main, merged only once coherent.
note

Cherry-picking transfers individual fixes between branches without merging everything. Because actively-supported lines can diverge over time, a fix that applies cleanly on one branch may not cherry-pick directly onto another. In that case, the change may need to be adapted to compile, integrate, and pass tests on the target branch, or, if the branches have diverged enough, implemented separately for each supported branch.

Track 1: additive releases

Low-risk, non-breaking features and bug fixes ship quickly on the current release train:

  1. The change lands on main (gated by Tier 1A CI like any PR).
  2. It is cherry-picked onto the current release line (stable) by the backport bot when you add a backport-stable label to the merged PR.
  3. release-plz opens a release PR on stable; merging it publishes 0.x.y.

Key points:

  • Short (about one day) bake. Additive releases (0.x.y, y ≥ 1) don't need a full release-candidate stage, but we do hold a brief bake, approximately one business day, before the crates.io publish, to re-verify compatibility against the downstream projects we maintain (the other language bindings). This is far lighter than the breaking train's bake; it exists to catch integration surprises. Larger integration challenges are avoided by deferring potentially-breaking changes to Track 2.
  • Compatibility is verified, not assumed. Every additive release is gated on cargo-semver-checks so an accidental break can't ship as a "patch."

Track 2: the breaking train

Breaking changes and larger refactors are batched onto a scheduled train:

  1. On the scheduled date, a release-candidate branch 0.(x+1).0-rc is cut from main, and its first build 0.(x+1).0-rc.1 is cut immediately (versions set, tagged, prerelease binaries built). RC branches are not published to crates.io (their name keeps them off every crates.io publish trigger), but each build does get a prerelease GitHub release with binaries so downstream consumers that depend on pre-built binaries can validate during the bake. See RC builds.
  2. Bake period: minimum three business days. Only bug fixes are accepted during the bake, and they follow upstream-first (fix on main, cherry-pick to the candidate). After fixes land, cut a fresh build (-rc.2, -rc.3, …) so downstream has updated binaries to test. Hold longer than three business days if needed to validate across the downstream projects we maintain.
  3. Promote (a deliberate, manual step): first snapshot the outgoing stable as v0.<old> so the retiring line is available for backports, then merge the candidate into stable. release-plz then opens the 0.(x+1).0 version/changelog PR on stable; merging it publishes the breaking release.

Cadence: scheduled, but not forced

  • Default rhythm: every two months, on the second Monday of each odd-numbered month at 16:00 UTC (when release-train-cut.yml cuts the candidate), published in advance so users can plan migrations. (We deliberately avoid a faster cadence: pre-1.0, breaking users frequently is too much churn.)
  • Skip if empty. If the date arrives with no breaking changes queued, we skip the train. A major bump that breaks everyone for no new value is pure cost, and it's safe to skip because Track 1 is already delivering value on the current release train.
  • Don't hold the train. If breaking changes are queued, the candidate is cut on the date regardless. An almost-finished breaking feature waits for the next train. This is what turns "we have a cadence" into "we have predictability."
  • Anchor on the cut date, not the release date, so the bake window absorbs slippage.

A side effect of skip-if-empty: the middle version number stops being a clock and becomes a true signal that a breaking change happened. Recency therefore comes from dated changelogs, not the version number.

Version numbering across a train

Every train advances the minor number by one, regardless of whether cargo-semver-checks detects a breaking change: a train is, by definition, a new minor line, and we want a clean, predictable number for it. Versions are set by hand (e.g. cargo set-version) rather than left to release-plz's semver detection, precisely because we are overriding that detection.

The convention:

  • main always carries the next release's version with a -dev suffix, e.g., 0.91.0-dev. Because main is never published, the -dev prerelease is purely a label that says "work in progress toward 0.91.0."
  • Cutting the train for 0.N.0 produces the release-candidate branch 0.N.0-rc (dropping the numeric suffix from the branch name; the number belongs to each build). Its builds are versioned 0.N.0-rc.1, 0.N.0-rc.2, … and, while never published to crates.io, are tagged and get prerelease GitHub-release binaries. On promotion the line becomes 0.N.0.
  • Right after the cut, main moves to 0.(N+1).0-dev so ongoing development is always numbered ahead of the line that's baking. This bump is committed to main automatically by release-train-cut.yml as part of the cut: no separate PR.
  • c2patool follows the same pattern on its own numbering (its own next minor), independent of c2pa. RC build numbers are kept in lockstep across the crates, so -rc.N identifies one coherent candidate.

Worked example (the first train, which is also the transition onto this convention):

Cratecurrent mainRC branchRC builds (baking)promotedmain after cut
c2pa / c2pa-c-ffi0.89.30.90.0-rc0.90.0-rc.1, 0.90.0-rc.2, …0.90.00.91.0-dev
c2patool0.26.720.27.0-rc0.27.0-rc.1, 0.27.0-rc.2, …0.27.00.28.0-dev

Steadily thereafter, main already carries 0.N.0-dev, so the train's release version is that number with -dev dropped, and main advances to 0.(N+1).0-dev.

Keeping additive changes additive

The model only works if we stay disciplined about keeping the fast lane non-breaking:

  • Review norm: "Can this ship additively? If yes, it goes out now. If it requires a break, it waits for the next train."
  • Forward-compatible tools: prefer #[non_exhaustive] (enums/structs), sealed traits, and default trait methods so future additions stay compatible.
  • Deprecate-then-remove: when we must break, add the replacement API additively and mark the old one #[deprecated]. Per the deprecation policy, both the deprecation mark and the eventual removal are made on main only, never backported: the deprecation surfaces to users when the next train is cut, and the removal rides the following train. The additive replacement can still ship on the current line, so users get the new API right away and a full train's window to migrate before the old one disappears.
  • Extract-with-re-export: moving code into a separate crate is additive as long as the public paths are preserved by re-exporting (pub use). See extracted crates.

Extracted crates: multi-repo

We are extracting stable, low-churn code out of c2pa-rs into independent crates, each in its own repository, so that this code is not rebuilt on every PR to c2pa-rs main. The first example is c2pa_cbor: the CBOR (de)serialization primitives used throughout C2PA manifests, now in their own contentauth/c2pa-cbor repo. These crates are versioned independently (lockstep versioning would re-couple the build times we're trying to separate).

Once a subproject lives in its own repo and is published, c2pa-rs depends on it by version (c2pa_cbor = "0.77") like any third-party crate. We use a normal caret requirement (not a pinned =x.y.z) and let Cargo.lock pin the exact version for reproducible builds: the same approach we take for any other dependency. That reframes the "wait period" from a scheduling problem into a dependency-edge problem:

  • Extracted crates do not follow this release process. Each upstream c2pa-* crate is released on a simple, as-needed basis. (Cut one when a change lands; update version as per cargo-semver-checks.) Because they rarely change, they have no meaningful wait period of their own. Incorporating those updates into c2pa-rs is treated exactly like any other change to c2pa-rs: a dependency upgrade may be backported to stable if it doesn't break c2pa-rs APIs; otherwise it waits for the next release change.
  • The extraction itself is a Track 1 change to c2pa-rs: depend on the published crate, delete the inlined module, re-export the same public paths. Additive; ships quickly on the current release train.
  • Classify each extracted crate as public or internal:
    • Public (re-exported): its types are part of c2pa's public API, so a breaking bump of the crate is breaking for c2pa's users and must ride a c2pa-rs train.
    • Internal (private dependency): its types never appear in c2pa's public API, so it can be bumped, including breaking bumps, freely, without involving the train.
  • Co-evolving changes (touching an extracted crate and c2pa-rs together) live behind a Cargo [patch] on c2pa-rs main during development:
    • [patch.crates-io] c2pa_cbor = { git = "…", branch = "…" } lets the inner loop and CI build against the in-flight crate without publishing.
    • A [patch] or git dependency must be settled, the dependency published at a real version and the patch removed, before c2pa-rs can cut any release, because crates.io requires every dependency to resolve to a published version. This is mechanically enforced by the patch-dependency guard.
  • Cross-repo canary: a scheduled canary builds c2pa-rs main against an extracted crate's main (via [patch]) so integration drift is surfaced early, while it's cheap.

Branch lifecycle and support

  • A 0.x release line is retired when its successor 0.(x+1).0 ships. By default we support only the latest stable line.
  • Backport exceptions to a retired line are rare and reserved for a correctness or security issue with no reasonable upgrade path for the affected consumer: in practice, security fixes and critical downstream emergencies only. Such a backport targets that line's v0.x branch.

Automation

Cutting a release is mostly a CI action rather than manual toil. The pieces:

release-plz

We use release-plz (via the GitHub Action wrapper), configured by release-plz.toml. Its two responsibilities are split across two workflows, both of which run on the release-line and release-candidate branches, never on main:

  • release-pr.yml runs release-plz release-pr: for each published crate it inspects commits since the last tag and opens/updates a release PR that bumps the version and updates the changelog. Because that PR targets a release-line branch, it runs the full Tier 1A + 1B + 2 suite (see validation gating).
  • release.yml runs release-plz release: when a release PR merges (a push to the release-line branch), it publishes the changed crates to crates.io, creates GitHub releases, and tags them (crate-name)-v(version). Those tags then drive the binary builds (library-release.yml on c2pa-v*, c2patool-release.yml on c2patool-v*): release.yml no longer builds binaries itself, which is what lets release-candidate builds produce the same binaries from the same tags (see RC builds). A push whose ref contains -rc never publishes to crates.io.

Binary builds are therefore entirely tag-driven, independent of how a tag was created:

  • library-release.yml builds the c2pa / c2pa-c-ffi native libraries for every supported target on any c2pa-v* tag.
  • c2patool-release.yml builds the c2patool CLI binaries and SBOMs on any c2patool-v* tag.

A tag whose name contains -rc. yields a prerelease GitHub release; nothing on either path publishes to crates.io.

How release-plz chooses a version, per crate:

  • If only bug-fix commits are detected, bump the patch number (y).
  • If API additions or breaking changes are detected, bump the middle number (x). (Pre-1.0, Cargo treats a middle-number bump as incompatible; this becomes the major-number bump after 1.0.)
  • For library crates, release-plz also downloads the most recent crates.io release and compares the API surface, taking the larger of (commit-implied, surface-implied) bumps.

The set of commit types that trigger a release is configured by release_commits in release-plz.toml (chore commits are ignored). Commit/PR titles must follow Conventional Commit syntax; see Commit lint.

info

You may manually edit a proposed changelog in the release PR, but those edits will be overwritten if another update is triggered: release-plz force-pushes to update an existing release PR.

Backport bot

To bring a merged main PR onto a release line, add a backport-<branch> label to it (e.g. backport-stable). On merge, backport.yml (using korthout/backport-action) cherry-picks the change and opens a PR against that branch. Because that PR targets a release-line branch, it must pass the full Tier 1A + 1B + 2 suite plus cargo-semver-checks before it can merge (see validation gating).

We use a self-contained GitHub Action rather than an external service. Mergify is a documented alternative if we ever need richer conflict handling or merge queues.

Upstream-first check: proactive

upstream-first-check.yml runs on every PR targeting a release-line or release-candidate branch and blocks the merge if the PR introduces a commit whose change is not already on main (compared by patch id via git cherry). This prevents drift rather than merely detecting it after the fact. Combined with branch protection that requires PRs on these branches, it makes "nothing originates on a release branch" enforceable.

Two exemptions keep it practical: the release-plz release PR (labeled release) may legitimately originate version-bump/changelog commits on the release branch, and a maintainer can add the upstream-first-verified label to a PR whose cherry-pick had to be adapted to compile on the target branch (so its patch id no longer matches main).

Reconciliation check: reactive

As a backstop to the proactive check above, a scheduled job, reconciliation.yml, runs git cherry main <release-branch>; anything present on the release branch but not on main (for example, from a direct push that bypassed a PR) means something originated on a release branch, violating upstream-first. The job opens (or updates) an issue so the change can be forward-ported and isn't lost across a major bump. We deliberately do not auto-merge a release branch back into main, since merging into the actively-refactored main is the conflict-prone direction.

Semver checks

semver-checks.yml runs cargo-semver-checks on every PR targeting a release-line branch (including backport PRs), baselining against the latest crates.io release. It catches a change that would be an accidental break shipping as an additive release. It does not run on -rc branches: the train is the intended place for breaking changes.

By default, cargo-semver-checks ignores features named unstable, nightly, bench, or no_std, and any feature whose name begins with _, unstable-, or unstable_. We rely on this: experimental features are gated behind unstable_<name> flags precisely so that changes confined to them are exempt from breaking-change detection and never force a version bump. An experimental API is free to change at will, as long as the change doesn't touch the public/stable API surface.

Patch-dependency guard

check-no-patch-deps.yml fails if a [patch] section or a git dependency is present. It runs on release-branch PRs and as a required prerequisite of release.yml, so a [patch] left over from co-developing an extracted crate can never leak into a publish.

Release-train cut

release-train-cut.yml runs every Monday and gates on a date check so it only acts on the second Monday of an odd-numbered month (or when dispatched manually with force: true). When it fires it computes the next breaking version, applies skip-if-empty, and, if there's breaking work to ship, pushes a new 0.(x+1).0-rc candidate branch from main, kicks off its first build by dispatching release-rc.yml, advances main to the next 0.(x+2).0-dev cycle (committed directly, no PR), and opens a release-train tracking issue describing the bake and the manual-promotion step. The main bump happens after the RC branch is cut, so the candidate is taken from pre-bump main. (Committing to main directly requires the RELEASE_PLZ_TOKEN to be allowed to bypass main's pull-request rule; see branch protection.)

Release-candidate builds

release-rc.yml cuts a numbered candidate build (-rc.1, -rc.2, …) from a candidate branch (0.N.0-rc). It sets the -rc.N versions on the branch (bumping c2pa/c2pa-c-ffi and c2patool in lockstep on the build number), commits, and pushes the c2pa-v…-rc.N and c2patool-v…-rc.N tags. Those tags trigger the tag-driven binary workflows above, which publish prerelease GitHub releases with binaries. Nothing here reaches crates.io: the build exists so downstream projects that consume pre-built binaries (rather than building from source) can validate the candidate during its bake.

The first build (-rc.1) is cut automatically when the train is cut. A maintainer re-runs this workflow (via workflow_dispatch on the RC branch) to cut a fresh build after bugfixes have been cherry-picked onto the branch during the bake. Tags are pushed with a PAT (RELEASE_PLZ_TOKEN) so the tag-driven binary workflows actually run: pushes made with the default GITHUB_TOKEN do not cascade into other workflows.

Cross-repo canary

canary-extracted-crates.yml builds c2pa-rs main against the main of each extracted crate via a throwaway [patch], and files an issue on failure. The [patch] exists only inside the runner; the patch-dependency guard guarantees it can never reach a release.

Experimental features build

experimental-features.yml builds, lints, and tests the SDK with every unstable_ feature enabled, on PRs and daily. It is non-blocking by design (continue-on-error, and not a required check): experimental features are community-supported and outside the CAI team's maintenance commitments, so main is never gated on their health. A persistent failure is grounds for disabling the feature, not for holding up the SDK. The required tier suites (1A/1B/2), the beta preflight, and the cross-repo canary all exclude unstable_ features from their build/test/lint jobs – the Tier 1A docs.rs preflight is the one deliberate exception, since experimental APIs are intentionally published to docs.rs.

Label sync

Labels the process depends on are version-controlled in .github/labels.yml and synced by labels.yml.

Validation gating

The support tiers map directly onto the branching model:

  • Merging to main requires Tier 1A: the merge gate for everyday development.
  • Any PR targeting a release-line (stable, v0.x) or release-candidate (*-rc*) branch must pass the full Tier 1A + 1B + 2 suite before it can merge. This includes backport PRs, RC bake bugfix PRs, and the release-plz release PR: anything headed for a published (or soon-to-be-published) artifact gets the most thorough validation we have. PRs targeting release lines additionally run cargo-semver-checks.
  • During a train's bake, Tier 1A + 1B + 2 also run on every push to the *-rc* branch.
  • All three tiers also run against main on a daily schedule (a nightly run), catching regressions that only appear under the heavier Tier 1B/2 configurations even when no release-targeting PR is open.
  • On a main PR you can opt into the full suite on demand by adding the check-release label (useful to assess release-readiness before a change is backported).

See docs/support-tiers.md for what each tier covers and why a configuration lands in a given tier.

Commit lint used for PR title enforcement

Because release-plz uses Conventional Commit syntax to generate changelogs, all commits to long-lived branches must follow it. We squash-merge PRs, and pr_title.yml checks that each PR title conforms, as configured by .commitlintrc.yml (the definitive specification).

A quick, non-authoritative summary: the PR title must have this exact format:

type(scope): description

The type must be one of (bold = preferred in most cases):

  • feat: a new feature. Use a ! immediately before the : to signal an API breaking change (which queues for the next train).
  • fix: a bug fix.
  • chore: maintenance; does not trigger a release PR and is omitted from the changelog.
  • docs: documentation.
  • build, ci, perf, refactor, revert, style, test, update (the last used by Dependabot).

scope is optional; if present it must be one of c2patool, export_schema, make_test_images, sdk, c2pa_c_ffi, or experimental. If omitted, drop the parentheses too. description is a short sentence, capitalized, no trailing period, preferably under 70 characters.

Use the experimental scope for any change confined to an experimental feature (for example feat(experimental): add widget export). It routes the change into a dedicated Experimental section of the changelog and keeps it from driving a minor/breaking bump – the change may still ride an ordinary patch release, but experimental APIs are outside semver, so never combine it with the breaking (!) marker.

note

If these rules change, keep .github/workflows/pr_title.yml and .commitlintrc.yml in sync.

Known issues

Failure to update downstream crates

In repos that host multiple crates, an earlier crate in the dependency chain can warrant a release while a crate that depends on it has no commits of its own. release-plz updates the downstream version reference but doesn't always cut a new release of the downstream crate. (See issue #2164 and PR #2196.) Workaround: post a no-op change (a whitespace tweak or comment) to the downstream crate to trigger a release PR. Tracked in #2298; remove this note once resolved upstream.

Left-behind release branches

release-plz sometimes creates a new release branch + PR instead of updating the existing one, leaving the old branch behind. To reduce noise, release-pr.yml deletes stale release-plz-* branches. Tracked in #2299; remove this note once resolved.

c2pa crate accidentally published a 1.0.0 release

An earlier tooling experiment accidentally published (and then yanked) c2pa 1.0.0. That version is permanently unavailable, so the eventual real 1.0 release will need a different number (probably 1.0.1).

Troubleshooting

How to recover if the publish step fails or partially fails

Keep the core mental model in mind (see release-plz). The following usually works when release-plz fails to publish one or more crates, though it may need adapting to the specific failure:

  • Read the logs in the Actions tab. (cargo publish uses a subtly different compilation environment than a normal build, which is a common root cause.)
  • Resolve the underlying issue.
  • Only for crates that failed to publish, manually revert their Cargo.toml and CHANGELOG.md on the release-line branch. release-plz only generates a new release PR for crates whose Cargo.toml version exactly matches crates.io; delete the failed CHANGELOG.md section too, or release-plz will error on the next PR.
  • Revert intra-project version references that failed to publish (e.g. a c2patool dependency on an unpublished c2pa version), and let release-plz re-introduce them.
  • Wait for release-plz to open a fresh release PR with the desired result, and otherwise avoid manually editing Cargo.toml: pushing release-plz outside its normal process tends to create more problems.

Using the rp-sandbox project to preflight release-plz changes

To vet a new version of release-plz or a config change, CAI team members can use the rp-sandbox project, which mirrors this repo's dependency structure with dummy crates. (Contact a maintainer for access.)

Branch protection

The upstream-first guarantees rely on release-line and release-candidate branches only receiving changes through PRs. Configure branch protection (a repository setting, not something this repo can commit) on main, stable, and each v0.* / *-rc* branch to:

  • Require a pull request before merging, so nothing is pushed directly, which is what makes the upstream-first check an effective gate rather than an after-the-fact report.
  • Require status checks to pass, including Tier 1A on main, and Tier 1A + 1B + 2, cargo-semver-checks, and the upstream-first check on release-line/RC branches (see validation gating).

Branch-name patterns (v0.*, *-rc*) can be covered with a single ruleset each so new release lines and candidates are protected automatically.

Some release automation pushes directly to protected branches and so must be on the ruleset bypass list: grant this to the identity behind RELEASE_PLZ_TOKEN:

  • release-train-cut.yml commits the next-dev-cycle bump straight to main, and release-rc.yml commits -rc.N version bumps straight to the RC branch. Both bypass the pull-request rule by design (they are mechanical version bumps, not reviewed changes).

One-time setup

Before the first train cuts over, create the stable branch from the latest published tag:

git fetch --tags
git branch stable c2pa-v0.89.0 # the most-recent c2pa release tag
git push origin stable

After that, the additive flow (backport-to-stable) and the train flow (release-train-cut.yml) operate as described above.