diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml new file mode 100644 index 00000000..7eb21041 --- /dev/null +++ b/.github/workflows/backport.yml @@ -0,0 +1,73 @@ +name: Backport + +# Cherry-picks a merged `main` PR onto a release-line branch (upstream-first: +# everything lands on `main` first, then is brought forward). Add a label of +# the form `backport-` to a PR; when it merges, this opens a backport +# PR against that branch: +# * backport-stable -> stable (the currently-active additive line) +# * backport-v0.28 -> v0.28 (a specific long-lived line) +# +# The opened backport PR is gated by Tier 1A (see ci.yml). To run the full +# beta-preflight suite on a risky backport, add the `check-release` label to +# the backport PR. +# +# Engine: korthout/backport-action (self-contained GitHub Action; no external +# service). Mergify is a documented alternative if richer conflict handling or +# merge queues are ever needed -- see docs/release-process.md. + +on: + pull_request_target: + types: [closed, labeled] + +permissions: + contents: write + pull-requests: write + +jobs: + backport: + name: Backport merged PR + if: >- + github.event.pull_request.merged == true && + github.event.pull_request.base.ref == 'main' && + contains(join(github.event.pull_request.labels.*.name, ','), 'backport-') + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_PLZ_ORG_TOKEN }} + + - name: Create backport PRs + id: backport + uses: korthout/backport-action@2e830a1d0b8269505846ddd407a70876913ad1f8 # v4.6.0 + with: + github_token: ${{ secrets.RELEASE_PLZ_ORG_TOKEN }} + comment_style: summary + # Labels look like `backport-`; the captured group is the + # target branch name. + label_pattern: '^backport-(?.+)$' + pull_title: '${pull_title} (backport #${pull_number})' + pull_description: | + Automated backport of #${pull_number} to `${target_branch}`. + + Upstream-first: this change already merged to `main`. If the + cherry-pick did not apply cleanly, adapt it so it compiles and + passes tests on this branch. + + # Arm GitHub's native auto-merge on each backport PR that was opened. The + # actual merge only fires once the target branch's protection rules are + # satisfied -- i.e. CI is green and at least one approving review is in -- + # so those requirements must be configured as branch protection on the + # release-line branches (CI passing + 1 required approval). Requires + # "Allow auto-merge" to be enabled in the repository settings. + - name: Enable auto-merge on backport PRs + if: steps.backport.outputs.created_pull_numbers != '' + env: + GH_TOKEN: ${{ secrets.RELEASE_PLZ_ORG_TOKEN }} + CREATED_PRS: ${{ steps.backport.outputs.created_pull_numbers }} + run: | + for pr in $CREATED_PRS; do + echo "Enabling auto-merge (squash) on backport PR #$pr" + gh pr merge "$pr" --auto --squash --repo "$GITHUB_REPOSITORY" + done diff --git a/.github/workflows/check-no-patch-deps.yml b/.github/workflows/check-no-patch-deps.yml new file mode 100644 index 00000000..139f1898 --- /dev/null +++ b/.github/workflows/check-no-patch-deps.yml @@ -0,0 +1,52 @@ +name: Check no patch/git deps + +# Reusable guard: fails if a `[patch]` section or a git dependency is present. +# `main`'s own `c2pa` dependency IS a git dependency by design -- it tracks +# c2pa-rs's `main` branch (see docs/release-process.md, "Tracking c2pa-rs +# main") so integration breaks surface immediately. That's fine on `main`, +# but it must never reach a release-line branch: crates.io requires every +# dependency to resolve to a published version. This is the mechanical +# enforcement of that split (note the triggers below deliberately exclude +# `main`). + +on: + workflow_call: + pull_request: + branches: + - stable + - 'v0.*' + - '*-rc*' + +jobs: + check: + name: No patch/git dependencies + + # Fork-PR trust is enforced by GitHub's "Fork pull request workflows from + # outside collaborators" Actions setting (see ci.yml), not in this + # workflow; `workflow_call` invocations run unconditionally. + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master@2026-06-30 + with: + toolchain: stable + + - name: Fail if a [patch] section is present + run: | + if grep -rEn '^\[patch' --include=Cargo.toml .; then + echo "::error::A [patch] section is present in Cargo.toml. Settle it to a published version before releasing." + exit 1 + fi + echo "No [patch] sections found." + + - name: Fail if a git dependency is present + run: | + if cargo metadata --format-version=1 --no-deps \ + | jq -e '.. | objects | select(.source? != null and (.source | tostring | test("git\\+")))' >/dev/null; then + echo "::error::A git dependency is present. Settle it to a published version before releasing." + exit 1 + fi + echo "No git dependencies found." diff --git a/.github/workflows/reconciliation.yml b/.github/workflows/reconciliation.yml new file mode 100644 index 00000000..a9e7617e --- /dev/null +++ b/.github/workflows/reconciliation.yml @@ -0,0 +1,132 @@ +name: Release-branch reconciliation + +# Reactive backstop for the upstream-first rule. The proactive guard is +# upstream-first-check.yml, which blocks a PR to a release branch that isn't +# already on `main`; this job catches anything that still slips through (e.g. a +# direct push that bypassed a PR). Everything is supposed to land on `main` +# first and only be cherry-picked onto release-line branches. This job detects +# the opposite -- a commit that exists on a release-line branch but not on +# `main` -- and opens an issue so the change can be forward-ported and isn't +# lost across a future major bump. +# +# It deliberately does NOT merge a release-line branch back into `main`: +# merging into the actively-refactored `main` is the conflict-prone direction +# and risks resurrecting code a refactor removed. +# +# Release-ceremony commits are excluded. A release line carries version/ +# changelog bookkeeping commits that are native to the release process and +# must NOT be forward-ported -- RC version bumps (release-rc.yml), the final +# release-prep commit, etc. `main` owns its own version (release-plz plus the +# dev-cycle bump in release-train-cut.yml), so replaying these onto `main` +# would only cause conflicts. They are identified structurally -- by a diff +# confined entirely to bookkeeping paths (Cargo.toml, Cargo.lock, CHANGELOG.md) +# -- rather than by author or commit message, both of which vary (some bumps +# are made by github-actions[bot], some by a maintainer by hand). Any commit +# that touches a non-bookkeeping file is real work and is still reported. + +on: + schedule: + - cron: '0 13 * * 1-5' # weekday mornings (UTC) + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + reconcile: + name: Detect drift from main + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Extend with active v0.* lines as they are created. + branch: [stable] + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + + - name: Find commits on release branch missing from main + id: drift + run: | + if ! git ls-remote --exit-code --heads origin "${{ matrix.branch }}" >/dev/null 2>&1; then + echo "Branch ${{ matrix.branch }} does not exist yet; nothing to reconcile." + echo "missing=" >> "$GITHUB_OUTPUT" + exit 0 + fi + git fetch origin main "${{ matrix.branch }}" + # `git cherry` marks with `+` the commits on the release branch whose + # change is NOT present (as an equivalent patch) on main. Cherry-picks + # already on main are patch-equivalent and are NOT marked, so the `+` + # set is exactly the commits native to the release line. + CANDIDATES=$(git cherry origin/main "origin/${{ matrix.branch }}" | sed -n 's/^+ //p') + + # Drop release-ceremony commits: those whose entire diff is confined + # to version/changelog bookkeeping files (see header comment). A + # commit touching any other path is real work and is reported. + MISSING="" + for sha in $CANDIDATES; do + files=$(git diff-tree --no-commit-id --name-only -r "$sha") + # -vE prints paths that are NOT bookkeeping; `|| true` keeps the + # step alive under `set -e` when grep matches nothing (all files + # are bookkeeping, i.e. a ceremony commit). + non_bookkeeping=$(printf '%s\n' "$files" \ + | grep -vE '(^|/)Cargo\.toml$|^Cargo\.lock$|(^|/)CHANGELOG\.md$' || true) + if [ -n "$files" ] && [ -z "$non_bookkeeping" ]; then + echo "Skipping release-ceremony commit $sha (version/changelog bookkeeping only)." + else + MISSING="${MISSING}+ ${sha}"$'\n' + fi + done + MISSING=$(printf '%s' "$MISSING" | sed '/^$/d') + + { + echo "missing<> "$GITHUB_OUTPUT" + + - name: Open or update reconciliation issue + if: steps.drift.outputs.missing != '' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const branch = '${{ matrix.branch }}'; + const missing = `${{ steps.drift.outputs.missing }}`; + const title = `Reconciliation: \`${branch}\` has commits missing from \`main\``; + const body = [ + `The following commits are on \`${branch}\` but not on \`main\` (an upstream-first violation):`, + '', + '```', + missing, + '```', + '', + 'Please forward-port these to `main`. This branch is intentionally **not** auto-merged back into `main`.', + ].join('\n'); + + // Avoid opening a duplicate every run: update an existing open issue. + const existing = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'reconciliation', + }); + const match = existing.data.find(i => i.title === title); + if (match) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: match.number, + body, + }); + } else { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + labels: ['reconciliation'], + body, + }); + } diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml new file mode 100644 index 00000000..8684e9a2 --- /dev/null +++ b/.github/workflows/release-pr.yml @@ -0,0 +1,60 @@ +name: Release-plz PR + +# Opens/updates the version-bump + changelog "release PR" on a release-line +# branch (stable, or a retired v0.x line). Merging that PR is what triggers a +# publish (release.yml). For a breaking train, this prepares the 0.(x+1).0 bump +# once the release-candidate branch has been promoted (merged) into stable. +# +# Release-candidate branches themselves do not run release-plz; they only bake +# under the Tier CI suites. This runs release-plz `release-pr` ONLY -- it never +# publishes. Publishing is handled separately by release.yml. +# See docs/release-process.md. + +permissions: + pull-requests: write + contents: write + +on: + push: + branches: + - stable + - 'v0.*' + workflow_dispatch: + +jobs: + release-pr: + name: Release-plz PR + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_PLZ_ORG_TOKEN }} + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master@2026-06-30 + with: + toolchain: stable + + - name: Run release-plz (release-pr) + uses: MarcoIeni/release-plz-action@064f4d1e36c843611ddf013be726beaa4ad804db # v0.5.129 + with: + command: release-pr + verbose: true + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_PLZ_ORG_TOKEN }} + CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_ORG_TOKEN }} + + # release-plz sometimes opens a new release branch + PR instead of + # updating the existing one, leaving the old branch behind. Delete stale + # release-plz branches to reduce noise, keeping the most recent. + - name: Clean up stale release-plz branches + run: | + git --no-pager branch --remote |\ + grep 'origin/release-plz-' |\ + sort -r |\ + tail -n +2 |\ + sed 's/origin\///' |\ + xargs -r -I {} git push origin --delete {} diff --git a/.github/workflows/release-rc.yml b/.github/workflows/release-rc.yml new file mode 100644 index 00000000..a83bc3cf --- /dev/null +++ b/.github/workflows/release-rc.yml @@ -0,0 +1,123 @@ +name: Cut release-candidate build + +# Cuts a numbered release-candidate BUILD (-rc.1, -rc.2, ...) from a release- +# candidate BRANCH (e.g. `0.28.0-rc`). This is what lets downstream consumers +# who depend on pre-built binaries validate a breaking train during its bake: +# each RC build is tagged and gets a *prerelease* GitHub release with binaries +# attached, but it is NEVER published to crates.io. +# +# How it fits together (see docs/release-process.md): +# * `release-train-cut.yml` creates the `0.N.0-rc` branch and invokes this +# workflow once to produce the first build, `-rc.1`. +# * A maintainer re-runs this workflow (workflow_dispatch on the RC branch) +# to cut `-rc.2`, `-rc.3`, ... after bugfixes have been cherry-picked onto +# the RC branch during the bake. +# +# This workflow only sets the version and pushes a tag. The tag then drives +# the binary build in c2patool-release.yml, which marks `-rc.` tags as +# prereleases. +# +# Publishing is gated by branch/tag names, not by this workflow: the tag +# contains `-rc.` and never matches a crates.io publish trigger (release.yml +# only runs on `stable` / `v0.*` branch pushes), so an RC build can never +# reach crates.io by construction. + +permissions: + contents: write + +on: + workflow_dispatch: + +jobs: + cut-rc: + name: Cut release-candidate build + runs-on: ubuntu-latest + steps: + - name: Refuse to run outside a release-candidate branch + run: | + # An RC branch is named `-rc` (no numeric suffix; the number + # lives on the build tag, not the branch). Guard against accidentally + # dispatching this against main / stable / a v0.x line. + case "${{ github.ref_name }}" in + *-rc) echo "Running on RC branch ${{ github.ref_name }}." ;; + *) + echo "::error::release-rc must be dispatched on a *-rc branch, not '${{ github.ref_name }}'." + exit 1 + ;; + esac + + - name: Checkout RC branch + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 + token: ${{ secrets.RELEASE_PLZ_ORG_TOKEN }} + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master@2026-06-30 + with: + toolchain: stable + + - name: Install cargo-edit + uses: baptiste0928/cargo-install@f204293d9709061b7bc1756fec3ec4e2cd57dec0 # v3.4.0 + with: + crate: cargo-edit + version: "0.13.6" + + - name: Compute release-candidate version + id: ver + run: | + CUR=$(cargo metadata --format-version=1 --no-deps | jq -r '.packages[0].version') + + # Strip any `-rc.N` or `-dev` prerelease suffix to get the base + # release version this branch is heading toward. + BASE="${CUR%-rc.*}"; BASE="${BASE%-dev}" + + # On a freshly cut branch the version is still `-dev` (no `-rc.`), + # so we start at 1; otherwise we increment the current number. + if [[ "$CUR" == *-rc.* ]]; then + N=$(( ${CUR##*-rc.} + 1 )) + else + N=1 + fi + + RC="${BASE}-rc.${N}" + { + echo "rc=$RC" + echo "tag=v${RC}" + } >> "$GITHUB_OUTPUT" + echo "Cutting c2patool $RC (build #$N)." + + - name: Fail if build tag already exists + run: | + if git ls-remote --exit-code --tags origin "${{ steps.ver.outputs.tag }}" >/dev/null 2>&1; then + echo "::error::Tag ${{ steps.ver.outputs.tag }} already exists; a build with this number was already cut." + exit 1 + fi + + - name: Set release-candidate version + run: cargo set-version "${{ steps.ver.outputs.rc }}" + + - name: Commit and tag + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git commit -am "chore: set ${{ github.ref_name }} to ${{ steps.ver.outputs.rc }}" + # Push the commit and the build tag. The tag triggers the binary + # build (c2patool-release.yml). Pushing with a PAT (RELEASE_PLZ_ORG_TOKEN) + # is required so that tag-triggered workflow actually runs -- pushes + # made with the default GITHUB_TOKEN do not cascade into other workflows. + git tag "${{ steps.ver.outputs.tag }}" + git push origin "HEAD:${{ github.ref_name }}" + git push origin "${{ steps.ver.outputs.tag }}" + + - name: Summary + run: | + { + echo "## Release-candidate build cut" + echo "" + echo "- \`c2patool\`: **${{ steps.ver.outputs.rc }}** (tag \`${{ steps.ver.outputs.tag }}\`)" + echo "" + echo "A prerelease GitHub release with binaries is building for this tag." + echo "Nothing is published to crates.io." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-train-cut.yml b/.github/workflows/release-train-cut.yml new file mode 100644 index 00000000..49ff5a34 --- /dev/null +++ b/.github/workflows/release-train-cut.yml @@ -0,0 +1,211 @@ +name: Release train cut + +# Cuts a release-candidate branch for the breaking-change "train". +# +# Cadence: the second Monday of each odd-numbered month, same schedule as +# c2pa-rs's own train cut, but staggered 30 minutes AFTER it (16:30 UTC vs. +# c2pa-rs's 16:00 UTC). main's `c2pa` dependency tracks c2pa-rs's `main` +# branch (see docs/release-process.md, "Tracking c2pa-rs main"), so cutting +# this train needs c2pa-rs's own cut to have already landed its dev-cycle +# bump on `main` first -- otherwise this RC could be cut from a c2pa-rs +# `main` state that's still mid-transition rather than settled just after +# the upstream cut. GitHub cron cannot express "second Monday" directly, so +# this runs every Monday and a date gate decides whether today is actually a +# scheduled train date. The whole thing can also be run manually +# (workflow_dispatch) with `force: true` to cut off-schedule. +# +# Policy implemented here (see docs/release-process.md): +# * Skip-if-empty: if `main` has no releasable commits beyond the current +# `stable` line, no train is cut. +# * Don't-hold-the-train: the RC is cut from `main` AS-IS on the date; +# unfinished breaking work waits for the next train. +# Promotion of the RC to a published release is a deliberate MANUAL step (a +# maintainer merges the RC branch into `stable` after the >=3-business-day bake). + +permissions: + contents: write + pull-requests: write + issues: write + +on: + schedule: + # Every Monday at 16:30 UTC -- 30 minutes after c2pa-rs's own train cut. + # The date gate below restricts actual cuts to the second Monday of + # odd-numbered months. + - cron: '30 16 * * 1' + workflow_dispatch: + inputs: + force: + description: 'Cut an RC even if today is not a scheduled train date' + type: boolean + default: false + +jobs: + gate: + name: Decide whether to cut today + runs-on: ubuntu-latest + outputs: + go: ${{ steps.check.outputs.go }} + steps: + - name: Evaluate cadence + id: check + run: | + MONTH=$(date -u +%m) + DAY=$(date -u +%d) + DOW=$(date -u +%u) # 1 = Monday + # The second Monday is the Monday whose day-of-month is 8..14. + # 10# forces base-10 so leading-zero months (08, 09) don't parse as octal. + if [ $((10#$MONTH % 2)) -eq 1 ] && [ "$DOW" = "1" ] && [ "$DAY" -ge 08 ] && [ "$DAY" -le 14 ]; then + echo "Today is the second Monday of an odd-numbered month: scheduled train date." + echo "go=true" >> "$GITHUB_OUTPUT" + elif [ "${{ inputs.force }}" = "true" ]; then + echo "Not a scheduled date, but force=true." + echo "go=true" >> "$GITHUB_OUTPUT" + else + echo "Not a scheduled train date; nothing to do." + echo "go=false" >> "$GITHUB_OUTPUT" + fi + + cut: + name: Cut release-candidate branch + needs: gate + if: needs.gate.outputs.go == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout main + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: main + fetch-depth: 0 + token: ${{ secrets.RELEASE_PLZ_ORG_TOKEN }} + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master@2026-06-30 + with: + toolchain: stable + + - name: Install cargo-edit + uses: baptiste0928/cargo-install@f204293d9709061b7bc1756fec3ec4e2cd57dec0 # v3.4.0 + with: + crate: cargo-edit + version: "0.13.6" + + - name: Compute train version and RC branch + id: ver + run: | + # A train always advances the MINOR number by one (see + # docs/release-process.md "Version numbering across a train"). `main` + # normally carries the next release's version as `0.N.0-dev`, so the + # release version is that with `-dev` dropped; if `main` is ever a + # plain `0.x.y` (e.g. right after the repo split, before the first + # train), bump the minor instead. + rel_minor() { + local v="$1" base="${1%-dev}" + if [ "$base" != "$v" ]; then + echo "$base" # 0.N.0-dev -> 0.N.0 + else + echo "$v" | awk -F. '{printf "0.%d.0", $2+1}' # 0.x.y -> 0.(x+1).0 + fi + } + next_dev() { echo "$1" | awk -F. '{printf "0.%d.0-dev", $2+1}'; } + + CUR=$(cargo metadata --format-version=1 --no-deps | jq -r '.packages[0].version') + REL=$(rel_minor "$CUR") + + # The RC branch is named "-rc" (no numeric suffix): the number + # belongs to each build (-rc.1, -rc.2, ...), not the branch. The name + # matches the Tier CI triggers (*-rc*) but NOT the publish triggers + # (stable / v0.*), so a candidate branch can never be published by + # construction. The per-build version/tag is set by release-rc.yml. + { + echo "rel=$REL" + echo "dev=$(next_dev "$REL")" + echo "dev_cycle=${REL}" # e.g. 0.28.0, for the commit message + echo "branch=${REL}-rc" # e.g. 0.28.0-rc + } >> "$GITHUB_OUTPUT" + + - name: Skip if RC branch already exists + id: exists + run: | + if git ls-remote --exit-code --heads origin "${{ steps.ver.outputs.branch }}" >/dev/null 2>&1; then + echo "Branch ${{ steps.ver.outputs.branch }} already exists; not cutting again." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Skip if nothing new to release (skip-if-empty) + if: steps.exists.outputs.skip != 'true' + id: empty + run: | + if git ls-remote --exit-code --heads origin stable >/dev/null 2>&1; then + git fetch origin stable + if [ -z "$(git cherry origin/stable main | grep '^+' || true)" ]; then + echo "main has no commits beyond stable; skipping this train." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + else + echo "No stable branch yet; treating as non-empty (first train)." + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create RC branch from main + if: steps.exists.outputs.skip != 'true' && steps.empty.outputs.skip != 'true' + run: | + git push origin "main:refs/heads/${{ steps.ver.outputs.branch }}" + + # Cut the first build (-rc.1) on the new branch: release-rc.yml sets the + # -rc.1 version, tags it, and that tag builds a prerelease binary. + # Dispatched with the PAT so its subsequent tag push triggers the binary + # workflow. + - name: Cut first release-candidate build (-rc.1) + if: steps.exists.outputs.skip != 'true' && steps.empty.outputs.skip != 'true' + env: + GH_TOKEN: ${{ secrets.RELEASE_PLZ_ORG_TOKEN }} + run: | + gh workflow run release-rc.yml --ref "${{ steps.ver.outputs.branch }}" + + # Advance `main` to the next dev cycle so ongoing development is always + # numbered ahead of the line that's baking. This runs AFTER the RC branch + # is cut, so the candidate is taken from pre-bump `main` and `main` then + # moves forward. Committed directly to `main` (no PR) -- this requires the + # RELEASE_PLZ_ORG_TOKEN to be allowed to bypass `main`'s pull-request rule. + - name: Advance main to next dev cycle + if: steps.exists.outputs.skip != 'true' && steps.empty.outputs.skip != 'true' + run: | + cargo set-version "${{ steps.ver.outputs.dev }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git commit -am "chore: begin ${{ steps.ver.outputs.dev_cycle }} dev cycle (c2patool ${{ steps.ver.outputs.dev }})" + git push origin HEAD:main + + - name: Open tracking issue for the train + if: steps.exists.outputs.skip != 'true' && steps.empty.outputs.skip != 'true' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const rel = '${{ steps.ver.outputs.rel }}'; + const dev = '${{ steps.ver.outputs.dev }}'; + const branch = '${{ steps.ver.outputs.branch }}'; + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Release train ${rel}: bake (>=3 business days)`, + labels: ['release-train'], + body: [ + `Release-candidate branch \`${branch}\` was cut from \`main\` for the **${rel}** train.`, + 'It is **not** published to crates.io; it bakes under the Tier CI suites.', + '', + `The first build, **${rel}-rc.1**, is being cut automatically by \`release-rc.yml\`. Each build is tagged and gets a **prerelease GitHub release with binaries** so downstream binary consumers can validate during the bake.`, + '', + `\`main\` has been advanced automatically to the next dev cycle (**${dev}**), so development stays numbered ahead of the baking line. No action needed unless that step failed.`, + '', + '- Bake at least **3 business days**; hold longer if downstream validation needs it.', + '- Only **bugfixes** are accepted during the bake, upstream-first: fix on `main`, then cherry-pick onto the RC branch.', + `- To cut a fresh candidate build after fixes land, run the **Cut release-candidate build** workflow (\`release-rc.yml\`) on \`${branch}\` — it bumps to the next \`-rc.N\` and rebuilds the binary.`, + `- When green, **manually promote**: snapshot the outgoing \`stable\` as \`v0.\` (for backports), then merge the RC branch into \`stable\`. release-plz then publishes \`${rel}\`.`, + '- The previous line is retired once this train ships.', + ].join('\n'), + }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..b1ec160a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,73 @@ +name: Release-plz publish + +# Publishes to crates.io and creates a GitHub release/tag. +# +# Under the open-source release process (see docs/release-process.md), +# releases are published ONLY from release-line branches -- never from +# `main` (which is nightly-like and intentionally unpublished) and never +# from a release-candidate branch (which bakes before promotion). A push to +# a release-line branch happens when a release-plz release PR (see +# release-pr.yml) is merged. +# +# release-plz creates the `v*` tag here; that tag then drives the binary +# build in c2patool-release.yml. This workflow does not build binaries +# itself -- keeping binary builds tag-driven means release-candidate builds +# (which never reach this workflow) produce the same binaries via +# release-rc.yml's tags. + +permissions: + pull-requests: write + contents: write + +on: + push: + branches: + - stable # currently-active release line + - 'v0.*' # long-lived per-line branches (backports to retired lines) + +jobs: + # Refuse to publish if a [patch]/git dependency is present. main's own + # `c2pa` dependency is a git dependency by design (see docs/release- + # process.md, "Tracking c2pa-rs main") -- that must never reach stable. + guard-no-patch-deps: + name: Guard against patch/git dependencies + uses: ./.github/workflows/check-no-patch-deps.yml + + release-plz: + name: Release-plz + runs-on: ubuntu-latest + needs: guard-no-patch-deps + # Defense in depth: release-candidate branches must never publish, even if + # one were ever added to the triggers above. RC branches are named + # `-rc` (no numeric suffix), so match on `-rc` rather than `-rc.`. + if: ${{ !contains(github.ref_name, '-rc') }} + + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_PLZ_ORG_TOKEN }} + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master@2026-06-30 + with: + toolchain: stable + + - name: Run release-plz (publish) + id: release-plz + uses: MarcoIeni/release-plz-action@064f4d1e36c843611ddf013be726beaa4ad804db # v0.5.129 + with: + command: release + verbose: true + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_PLZ_ORG_TOKEN }} + CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_ORG_TOKEN }} + + - name: Debug release-plz outputs + env: + RELEASES: ${{ steps.release-plz.outputs.releases }} + run: | + echo "=== Release-plz releases ===" + echo "$RELEASES" + echo "===========================" diff --git a/.github/workflows/upstream-first-check.yml b/.github/workflows/upstream-first-check.yml new file mode 100644 index 00000000..e2c1f11a --- /dev/null +++ b/.github/workflows/upstream-first-check.yml @@ -0,0 +1,69 @@ +name: Upstream-first check + +# Proactive guard for the upstream-first rule (see docs/release-process.md): +# every change must land on `main` before it is brought onto a release-line +# (`stable`, `v0.*`) or release-candidate (`*-rc*`) branch. This blocks a PR to +# such a branch if it introduces a commit whose change is not already present on +# `main`, so drift is prevented rather than merely detected after the fact. +# +# This complements the reactive reconciliation.yml job (which catches anything +# that still slips through, e.g. a direct push). To make it effective, enable +# branch protection on the release-line/RC branches requiring PRs and this +# check -- see docs/release-process.md ("Branch protection"). +# +# Exemptions (checked inside the job so the required check always reports a +# conclusive pass/fail rather than being skipped): +# * The release-plz release PR (labeled `release`) legitimately originates +# version-bump/changelog commits on the release branch. +# * If a cherry-pick had to be adapted to compile on the target branch (so its +# patch id no longer matches `main`), a maintainer can add the +# `upstream-first-verified` label to attest the change is already upstream. + +on: + pull_request: + branches: + - stable + - 'v0.*' + - '*-rc*' + types: [opened, reopened, synchronize, labeled, unlabeled] + +permissions: + contents: read + +jobs: + verify: + name: Verify commits are already on main + runs-on: ubuntu-latest + steps: + - name: Checkout PR head + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Check that every PR commit is already on main + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + run: | + if echo "$LABELS" | grep -qE '"(release|upstream-first-verified)"'; then + echo "Exempt (release or upstream-first-verified label present); skipping check." + exit 0 + fi + + git fetch --no-tags origin main "$BASE_REF" + MERGE_BASE=$(git merge-base "origin/$BASE_REF" HEAD) + + # `git cherry` compares by patch id: commits this PR adds on top of its + # base (MERGE_BASE..HEAD) that have NO equivalent on main are marked '+'. + MISSING=$(git cherry origin/main HEAD "$MERGE_BASE" | grep '^+' || true) + if [ -n "$MISSING" ]; then + echo "::error::Upstream-first violation: these commits are not yet on \`main\`:" + git cherry -v origin/main HEAD "$MERGE_BASE" | grep '^+' + echo "" + echo "Land the change on 'main' first, then cherry-pick it here." + echo "If this is an adapted cherry-pick of a change already on 'main'," + echo "add the 'upstream-first-verified' label to acknowledge, then re-run." + exit 1 + fi + echo "All PR commits are already present on 'main'." diff --git a/docs/release-process.md b/docs/release-process.md new file mode 100644 index 00000000..74cc99ab --- /dev/null +++ b/docs/release-process.md @@ -0,0 +1,191 @@ +# Open-source release process + +This document describes how we release `c2patool` and the branching model +that supports it. It's adapted from +[c2pa-rs's release process](https://github.com/contentauth/c2pa-rs/blob/main/docs/release-process.md), +which this repo split off from -- the branching model, cadence, and +automation are deliberately kept the same for continuity, minus everything +that only made sense for coordinating multiple crates in one repo. 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 changes. +* **Be stable and predictable as much as possible for users**: 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 tool isn't whether a change is "big": it's whether it breaks their scripts or workflows. An additive feature costs them nothing; a changed or removed flag/behavior forces them to adapt. So we govern those two kinds of change on two different tracks, which map directly onto pre-1.0 Cargo semantics: + +| Change kind | Version slot | Cargo treats it as | Track | +| -- | -- | -- | -- | +| Additive / non-breaking | `0.x.y` (bump `y`) | compatible | **Track 1**: fast, on the current train | +| Breaking | `0.x.0` (bump `x`) | incompatible | **Track 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 + +| Branch | Role | Published to crates.io? | +| -- | -- | -- | +| `main` | Always green but unstable ("nightly-like"). It must always compile and pass tests, but its behavior is **not** guaranteed stable. It also tracks c2pa-rs's own `main` (see [Tracking c2pa-rs main](#tracking-c2pa-rs-main) below), so it may pick up upstream changes ahead of any c2pa release. | **No** | +| `stable` | Tracks 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.27`) | 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. + +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. Two mechanisms enforce it: a proactive [upstream-first check](#upstream-first-check-proactive) on every PR to a release branch, and a scheduled [reconciliation check](#reconciliation-check-reactive) 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. + +## Tracking c2pa-rs main + +`main`'s `Cargo.toml` depends on `c2pa` via a **git dependency** on c2pa-rs's own `main` branch, not a crates.io version -- so this repo continuously integration-tests against c2pa-rs's latest in-development code instead of only discovering a break once a new `c2pa` version publishes. A scheduled workflow, [`track-c2pa-rs-main.yml`](https://github.com/contentauth/c2patool/blob/main/.github/workflows/track-c2pa-rs-main.yml), keeps this fresh: it runs `cargo update -p c2pa` on weekday mornings and pushes directly to `main` if `Cargo.lock` changed. If the update ever breaks the build, CI on `main` goes red like any other main-breaking change. + +`stable` (and any `v0.*` line) uses a real crates.io version instead, since crates.io requires every dependency to resolve to a published version. [`check-no-patch-deps.yml`](#patch-dependency-guard) mechanically enforces that this git dependency never reaches a release branch. + +This means the [release train](#release-train-cut)'s cadence is deliberately staggered 30 minutes *after* c2pa-rs's own train cut (16:30 UTC vs. c2pa-rs's 16:00 UTC): cutting this train needs c2pa-rs's own cut to have already landed its dev-cycle bump on `main` first. + +## 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](#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 things are working as expected. + +## 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 (version set, tagged, prerelease binary 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](#release-candidate-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. +3. **Promote** (a deliberate, manual step): first snapshot the outgoing `stable` as `v0.` so the retiring line is available for backports, then force-push the candidate onto `stable`. We force-push rather than merge so that `stable`'s history becomes exactly the coherent set of changes made on `main`, superseding whatever adaptations were needed while manually backporting Track 1 fixes onto the old `stable` line. `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:30 UTC** (30 minutes after c2pa-rs's own train cut -- see [Tracking c2pa-rs main](#tracking-c2pa-rs-main)), published in advance so users can plan migrations. +* **Skip if empty.** If the date arrives with no breaking changes queued, we skip the 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. +* **Anchor on the cut date,** not the release date, so the bake window absorbs slippage. + +### Version numbering across a train + +Every train advances the **minor** number by one, regardless of whether the change is actually breaking: 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. + +The convention: + +* **`main` always carries the *next* release's version with a `-dev` suffix**, e.g., `0.28.0-dev`. Because `main` is never published, the `-dev` prerelease is purely a label that says "work in progress toward 0.28.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`](#release-train-cut) as part of the cut: no separate PR. + +## 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." +* **Deprecate-then-remove:** when we must break, add the replacement behavior additively and mark the old one deprecated (a warning in the CLI, a note in the docs) before removing it, giving users a full train's window to migrate. + +## 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. 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`](https://release-plz.dev) (via the [GitHub Action wrapper](https://github.com/release-plz/action)), configured by [`release-plz.toml`](https://github.com/contentauth/c2patool/blob/main/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`](https://github.com/contentauth/c2patool/blob/main/.github/workflows/release-pr.yml) runs `release-plz release-pr`: it inspects commits since the last tag and opens/updates a **release PR** that bumps the version and updates the changelog. +* [`release.yml`](https://github.com/contentauth/c2patool/blob/main/.github/workflows/release.yml) runs `release-plz release`: when a release PR merges (a push to the release-line branch), it publishes to crates.io, creates a GitHub release, and tags it `v(version)`. That tag then drives the binary build ([`c2patool-release.yml`](https://github.com/contentauth/c2patool/blob/main/.github/workflows/c2patool-release.yml) on any `v*` tag): `release.yml` doesn't build binaries itself, which is what lets release-candidate builds produce the same binaries from the same tags (see [RC builds](#release-candidate-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. A tag whose name contains `-rc.` yields a **prerelease** GitHub release; nothing publishes to crates.io in that case. + +How `release-plz` chooses a version: + +* If only bug-fix commits are detected, bump the patch number (`y`). +* If 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.) + +The set of commit types that trigger a release is configured by `release_commits` in [`release-plz.toml`](https://github.com/contentauth/c2patool/blob/main/release-plz.toml) (chore commits are ignored). + +> [!IMPORTANT] +> 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-` label to it (e.g. `backport-stable`). On merge, [`backport.yml`](https://github.com/contentauth/c2patool/blob/main/.github/workflows/backport.yml) (using [`korthout/backport-action`](https://github.com/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 suite before it can merge (see [validation gating](#validation-gating)). + +### Upstream-first check: proactive + +[`upstream-first-check.yml`](https://github.com/contentauth/c2patool/blob/main/.github/workflows/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`). Combined with [branch protection](#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`](https://github.com/contentauth/c2patool/blob/main/.github/workflows/reconciliation.yml), runs `git cherry main `; anything present on the release branch but **not** on `main` means something originated on a release branch, violating upstream-first. The job opens (or updates) an issue so the change can be forward-ported. We deliberately do **not** auto-merge a release branch back into `main`. + +### Patch-dependency guard + +[`check-no-patch-deps.yml`](https://github.com/contentauth/c2patool/blob/main/.github/workflows/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` -- note that `main`'s own `c2pa` git dependency (see [Tracking c2pa-rs main](#tracking-c2pa-rs-main)) is expected and fine there; this guard just makes sure it never reaches a release branch. + +### Release-train cut + +[`release-train-cut.yml`](https://github.com/contentauth/c2patool/blob/main/.github/workflows/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`](#release-candidate-builds), **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. + +### Release-candidate builds + +[`release-rc.yml`](https://github.com/contentauth/c2patool/blob/main/.github/workflows/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` version on the branch, commits, and pushes the `v…-rc.N` tag. That tag triggers the tag-driven binary build above, publishing a **prerelease** GitHub release with binaries. Nothing here reaches crates.io. + +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_ORG_TOKEN`) so the tag-driven binary workflow actually runs: pushes made with the default `GITHUB_TOKEN` do not cascade into other workflows. + +## Validation gating + +* **Merging to `main`** requires **Tier 1A** (`ci.yml`): the merge gate for everyday development. +* **Any PR targeting a release-line (`stable`, `v0.x`) or release-candidate (`*-rc*`) branch** must pass Tier 1A 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 same validation. +* During a train's bake, Tier 1A also runs on every push to the `*-rc*` branch. + +Commit-lint enforcement of PR titles (as c2pa-rs does via `pr_title.yml`/`.commitlintrc.yml`) and a dedicated `docs/support-tiers.md` are not yet ported to this repo -- follow-up work, not covered here. + +## Troubleshooting + +### How to recover if the publish step fails or partially fails + +Keep the core mental model in mind (see [`release-plz`](#release-plz)). The following usually works when `release-plz` fails to publish, though it may need adapting to the specific failure: + +* **Read the logs** in the [Actions tab](https://github.com/contentauth/c2patool/actions/workflows/release.yml). (`cargo publish` uses a subtly different compilation environment than a normal build, which is a common root cause.) +* **Resolve the underlying issue.** +* **If the failure happened partway through, manually revert `Cargo.toml` and `CHANGELOG.md`** on the release-line branch. `release-plz` only generates a new release PR when `Cargo.toml`'s version exactly matches crates.io; delete the failed `CHANGELOG.md` section too, or `release-plz` will error on the next PR. +* **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. + +## 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](#upstream-first-check-proactive) an effective gate rather than an after-the-fact report. +* **Require status checks to pass**, including Tier 1A on `main`, and Tier 1A plus the upstream-first check on release-line/RC branches (see [validation gating](#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_ORG_TOKEN`: + +* [`release-train-cut.yml`](#release-train-cut) commits the next-dev-cycle bump straight to `main`, [`release-rc.yml`](#release-candidate-builds) commits `-rc.N` version bumps straight to the RC branch, and [`track-c2pa-rs-main.yml`](#tracking-c2pa-rs-main) commits its `Cargo.lock` refresh straight to `main`. All three bypass the pull-request rule by design (they are mechanical, unreviewed-by-nature changes). + +## One-time setup + +The `stable` and `main` branches, and the full tag history, already exist from the repo split -- there's no equivalent of c2pa-rs's original "create `stable` from the latest tag" step to do here. What's still needed before any of this can actually run: the repo secrets, labels, and branch protection listed in the companion PR that added this document. diff --git a/release-plz.toml b/release-plz.toml new file mode 100644 index 00000000..2832237d --- /dev/null +++ b/release-plz.toml @@ -0,0 +1,54 @@ +[changelog] +body = """ + +## [{{ version | trim_start_matches(pat="v") }}]{%- if release_link -%}({{ release_link }}){% endif %} +_{{ timestamp | date(format="%d %B %Y") }}_ +{% for group, commits in commits | group_by(attribute="group") -%} +{%- if group != "chore" %} +### {{ group | upper_first }} + +{% for commit in commits -%} +{%- if commit.scope and commit.scope != package -%} +* *({{commit.scope}})* {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}{%- if commit.links %} ({% for link in commit.links %}[{{link.text}}]({{link.href}}) {% endfor -%}){% endif %} +{% else -%} +* {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }} +{% endif -%} +{%- endfor %} + +{%- endif %} + +{%- endfor -%} +""" + +commit_parsers = [ + # Changes to experimental features (scope `experimental`, see + # docs/experimental-features.md) are grouped into their own changelog + # section regardless of type, so consumers who opt into an `unstable_` + # feature have a human-readable signal even though these changes don't + # drive a minor/breaking version bump. This must stay first so it wins + # over the type-based parsers below. + { message = '^\w+\(experimental\)', group = "experimental" }, + { message = "^feat", group = "added" }, + { message = "^changed", group = "changed" }, + { message = "^deprecated", group = "deprecated" }, + { message = "^fix", group = "fixed" }, + { message = "^security", group = "security" }, + { message = "^chore", group = "chore" }, + { message = "^update", group = "updated dependencies" }, + { message = "^doc", group = "documented" }, + { message = "^.*", group = "other" }, +] + +[workspace] +dependencies_update = true +features_always_increment_minor = false +pr_labels = ["release"] +release_always = false +# Only these commit types trigger a release PR. This keeps chore-only commits +# (including chore cherry-picks brought over by the backport bot) from +# generating spurious release PRs on a release-line branch. +# TODO: Commented due to a bug with release-plz, see https://github.com/contentauth/c2pa-rs/issues/2229 +# release_commits = "^(feat|fix|docs|perf|refactor|revert|test|update)[!]?[(:]" + +[[package]] +name = "c2patool"