From d6d33681d22a9c5fa6f06f5c3bf0fb2f10432980 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 2 Oct 2026 14:43:35 -0700 Subject: [PATCH] ci: publish only from release (#4738) Co-authored-by: Claude Opus 5.5 --- .github/justfile | 4 ++ .github/workflows/alert.yml | 1 + .github/workflows/back-merge.yml | 51 ++++++++++++++++++ .github/workflows/release-brew.yml | 12 ++++- .github/workflows/release-go.yml | 12 +++-- .github/workflows/release-js.yml | 4 +- .github/workflows/release-kt-lib.yml | 11 ++-- .github/workflows/release-py.yml | 4 +- .github/workflows/release-rs.yml | 11 ++-- .github/workflows/release-swift-lib.yml | 13 ++--- doc/bin/gstreamer.md | 4 +- doc/setup/install.md | 10 ++-- flake.nix | 2 +- rs/moq-gst/README.md | 8 +-- sh/gh/back-merge.sh | 69 +++++++++++++++++++++++++ 15 files changed, 180 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/back-merge.yml create mode 100755 sh/gh/back-merge.sh diff --git a/.github/justfile b/.github/justfile index 1bccfc5338..f73be6168b 100644 --- a/.github/justfile +++ b/.github/justfile @@ -34,6 +34,10 @@ formula *args: install-formula *args: python3 sh/gh/install-formula.py "$@" +# Open the pull request that merges `release` back into `main`, queued to land as a merge commit. +back-merge: + sh/gh/back-merge.sh + # Trigger the apt-repo and rpm-repo workflows for a release tag. [positional-arguments] trigger-repo-publish *args: diff --git a/.github/workflows/alert.yml b/.github/workflows/alert.yml index 2c4084d32e..d0134bcb4d 100644 --- a/.github/workflows/alert.yml +++ b/.github/workflows/alert.yml @@ -28,6 +28,7 @@ on: # request. workflows: - apt-repo + - Back-merge - Cache - Cachix - Docker diff --git a/.github/workflows/back-merge.yml b/.github/workflows/back-merge.yml new file mode 100644 index 0000000000..e03ac6a2b3 --- /dev/null +++ b/.github/workflows/back-merge.yml @@ -0,0 +1,51 @@ +name: Back-merge + +# Every push to `release` (a cut, a backport, or release-plz's version bump) +# merges `release` back into `main`, so trunk carries the published versions +# and CHANGELOGs and the next cut starts from an advanced merge base. +# +# It lands through a pull request as a merge commit, never a squash: a squash +# leaves the merge base at the last cut, so a second publish before the next +# cut would conflict. + +on: + push: + branches: [release] + # Re-running by hand, e.g. after resolving a conflict that failed a run. + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: back-merge + cancel-in-progress: false + +jobs: + back-merge: + name: Back-merge + runs-on: ubuntu-latest + if: github.repository_owner == 'moq-dev' + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: ./.github/actions/just + + # The app token, not GITHUB_TOKEN: a pull request GITHUB_TOKEN opens + # triggers no workflows, so Check and Test would never report. + - name: Generate moq-bot token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + id: token + with: + client-id: ${{ secrets.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + + - name: Merge release into main + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + run: just gh back-merge diff --git a/.github/workflows/release-brew.yml b/.github/workflows/release-brew.yml index 5e8594de05..d31ca30f36 100644 --- a/.github/workflows/release-brew.yml +++ b/.github/workflows/release-brew.yml @@ -33,12 +33,22 @@ permissions: jobs: publish: name: Publish formula to homebrew-tap - if: ${{ github.event.workflow_run.conclusion == 'success' && startsWith(github.event.workflow_run.head_branch, 'moq-') }} + # workflow_run has the base repository's privileges and this job checks out + # the triggering commit, so accept only a successful tag push in this + # repository: a fork PR can rename a workflow and its branch to match. + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_repository.full_name == github.repository && + startsWith(github.event.workflow_run.head_branch, 'moq-') runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + # workflow_run runs the default branch's file, whose checkout would + # otherwise take trunk rather than the tag that was released. + ref: ${{ github.event.workflow_run.head_sha }} persist-credentials: false - uses: ./.github/actions/just diff --git a/.github/workflows/release-go.yml b/.github/workflows/release-go.yml index bb817841ac..759d4ad19c 100644 --- a/.github/workflows/release-go.yml +++ b/.github/workflows/release-go.yml @@ -8,7 +8,7 @@ name: Release Go # (registry is the gate, like release-plz / the PyPI check in release.sh). # # Triggers: -# * push to main — a wrapper API or VERSION change. The ffi require is +# * push to release — a wrapper API or VERSION change. The ffi require is # pinned to the current crate version, whose mirror tag # already exists in steady state. If it's missing (a # release is mid-flight) or rs/moq-ffi changed since @@ -20,13 +20,13 @@ name: Release Go # Chaining off the upstream run guarantees the mirror # tag is already present, so there is nothing to poll. # -# Stray main pushes are harmless: publish-wrapper.sh skips when the staged tree +# Stray release pushes are harmless: publish-wrapper.sh skips when the staged tree # matches the mirror, so unchanged content never mints an empty patch release. on: push: branches: - - main + - release workflow_run: workflows: ["Release Go FFI"] types: @@ -62,6 +62,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} # Tags, for the unreleased ffi check. fetch-depth: 0 persist-credentials: false @@ -84,7 +85,7 @@ jobs: echo "wrapper line: $LINE" # The ffi version the wrapper should require: the version of the ffi that - # just published (workflow_run), or the current crate version (main push). + # just published (workflow_run), or the current crate version (release push). - name: Resolve ffi version id: ffi env: @@ -100,7 +101,7 @@ jobs: fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" - # On a main push the wrapper pins the current crate's ffi version, but it + # On a release push the wrapper pins the current crate's ffi version, but it # may call API added to rs/moq-ffi since that version was tagged. - name: Check for unreleased ffi changes (push only) id: ffi-diff @@ -180,6 +181,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} persist-credentials: false - uses: ./.github/actions/just diff --git a/.github/workflows/release-js.yml b/.github/workflows/release-js.yml index 422a685f12..81b8f36f72 100644 --- a/.github/workflows/release-js.yml +++ b/.github/workflows/release-js.yml @@ -6,12 +6,12 @@ permissions: on: push: - branches: [main] + branches: [release] paths: - 'js/**' - '.github/workflows/release-js.yml' # Dry-run: builds, packs, and runs `deno publish --dry-run`, which catches - # JSR-only failures before they break a release on main. + # JSR-only failures before they break a release on `release`. pull_request: paths: - 'js/**' diff --git a/.github/workflows/release-kt-lib.yml b/.github/workflows/release-kt-lib.yml index f3947ef2ac..32bc3eaf14 100644 --- a/.github/workflows/release-kt-lib.yml +++ b/.github/workflows/release-kt-lib.yml @@ -22,8 +22,7 @@ name: Release Kotlin lib on: push: branches: - - main - - dev + - release paths: - "kt/moq/**" - "kt/gradle.properties" @@ -41,7 +40,8 @@ on: - "sh/gh/release.sh" - "rs/moq-ffi/build.sh" # Manual trigger so a wrapper bump can be published once the bindings it - # depends on land on Maven Central (the guard below skips otherwise). + # depends on land on Maven Central (the guard below skips otherwise). Only a + # dispatch on `release` publishes. workflow_dispatch: workflow_run: workflows: ["Release Kotlin FFI"] @@ -144,7 +144,10 @@ jobs: name: Publish wrapper needs: [check-version] # PRs always run (dry-run). Pushes publish only when the version is new. - if: github.event_name == 'pull_request' || needs.check-version.outputs.should_publish == 'true' + if: >- + github.event_name == 'pull_request' || + (needs.check-version.outputs.should_publish == 'true' && + (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/release')) # Only publishing runs share the serialized group. GitHub keeps a single # pending run per group and drops the older one, so a PR or a no-op push # sharing it silently cancelled a queued release. Scoped to this job so runs diff --git a/.github/workflows/release-py.yml b/.github/workflows/release-py.yml index beb00e7dad..51fb6f5bd5 100644 --- a/.github/workflows/release-py.yml +++ b/.github/workflows/release-py.yml @@ -5,7 +5,7 @@ name: Release Py # depends on are built separately (release-py-ffi.yml). # # Versioned independently of the moq-ffi crate: bump `version` in -# py/moq-rs/pyproject.toml by hand in a normal PR. On merge to main this reads +# py/moq-rs/pyproject.toml by hand in a normal PR. On merge to `release` this reads # that version and publishes only if it isn't already on PyPI. That registry # check is the release gate (the same model release-plz uses for crates), so # there's no separate tag to remember. When wrapper and FFI changes land @@ -15,7 +15,7 @@ name: Release Py on: push: branches: - - main + - release paths: - "py/moq-rs/**" - ".github/workflows/release-py.yml" diff --git a/.github/workflows/release-rs.yml b/.github/workflows/release-rs.yml index bc111c9d81..de27458731 100644 --- a/.github/workflows/release-rs.yml +++ b/.github/workflows/release-rs.yml @@ -12,7 +12,7 @@ concurrency: on: push: branches: - - main + - release # A run costs the better part of an hour and they serialize on the # concurrency group above, so a push that can't change a crate's version # or its API shouldn't queue one. @@ -26,7 +26,8 @@ on: - "flake.lock" - ".release-plz.toml" - ".github/workflows/release-rs.yml" - # Releasing by hand, e.g. after a skipped push or a cold cache. + # Releasing by hand, e.g. after a skipped push or a cold cache. Dispatch it + # on `release`; the job refuses any other ref. workflow_dispatch: jobs: @@ -34,7 +35,7 @@ jobs: name: Plz runs-on: ubuntu-latest - if: ${{ github.repository_owner == 'moq-dev' }} + if: ${{ github.repository_owner == 'moq-dev' && github.ref == 'refs/heads/release' }} env: # release-plz copies each crate into its own temp directory to compare it @@ -83,7 +84,9 @@ jobs: # Its own scope rather than the shared store cache.yml warms: this holds # cargo-semver-checks rustdoc, not check and test artifacts, and mixing # the two would have each restoring a target tree built for the other. - # This workflow only runs on `main`, so it is the sole writer of its scope. + # This workflow only runs on `release`, so it is the sole writer of its scope. + # The cache action saves push runs only on the default branch, so pushes + # here restore without saving; a dispatch on `release` refreshes it. # # The action's shim is what gets mbx in here at all. release-plz spawns # Cargo and cargo-semver-checks itself, so no environment variable reaches diff --git a/.github/workflows/release-swift-lib.yml b/.github/workflows/release-swift-lib.yml index 2684aa4f59..7283b8dfc1 100644 --- a/.github/workflows/release-swift-lib.yml +++ b/.github/workflows/release-swift-lib.yml @@ -5,7 +5,7 @@ name: Release Swift Lib # on are built separately (release-swift-ffi.yml). # # Versioned independently of the moq-ffi crate: bump `swift/VERSION` by hand in a -# normal PR. On merge to main/dev this reads that version and publishes only if +# normal PR. On merge to `release` this reads that version and publishes only if # it isn't already tagged on the mirror. That tag check is the release gate (the # same model release-plz uses for crates), so there's no separate tag to # remember. When wrapper and FFI changes land together, publication waits for @@ -15,8 +15,7 @@ name: Release Swift Lib on: push: branches: - - main - - dev + - release paths: - "swift/VERSION" - "swift/Package.swift.template" @@ -46,8 +45,8 @@ on: permissions: contents: read -# Separate PR builds from mainline publishes so a PR run can't queue ahead of -# (and delay) a release on main. +# Separate PR builds from publishes so a PR run can't queue ahead of (and +# delay) a release. concurrency: group: release-swift-lib-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'publish' }} cancel-in-progress: false @@ -68,7 +67,7 @@ jobs: version: ${{ steps.version.outputs.version }} ffi_version: ${{ steps.version.outputs.ffi_version }} ffi_unreleased: ${{ steps.ffi-diff.outputs.unreleased }} - # On PRs we only build (dry-run). On main/dev we publish when the version + # On PRs we only build (dry-run). On `release` we publish when the version # isn't already tagged on the mirror. publish: ${{ (github.event_name == 'push' || github.event_name == 'workflow_run') && steps.ffi-diff.outputs.unreleased != 'true' && steps.ffi-gate.outputs.exists == 'true' && steps.gate.outputs.exists == 'false' }} @@ -149,6 +148,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 with: + ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} persist-credentials: false - uses: ./.github/actions/just @@ -196,6 +196,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 with: + ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} persist-credentials: false - uses: ./.github/actions/just diff --git a/doc/bin/gstreamer.md b/doc/bin/gstreamer.md index 117064a59a..e51a5fc504 100644 --- a/doc/bin/gstreamer.md +++ b/doc/bin/gstreamer.md @@ -10,10 +10,10 @@ subscribes to a broadcast and exposes one source pad per rendition. ```bash # Inspect (Nix bundles the plugin with gst-launch) -nix shell github:moq-dev/moq#moq-gst --command gst-inspect-1.0 moq +nix shell github:moq-dev/moq/release#moq-gst --command gst-inspect-1.0 moq # Play the public test broadcast -nix shell github:moq-dev/moq#moq-gst --command gst-launch-1.0 -e \ +nix shell github:moq-dev/moq/release#moq-gst --command gst-launch-1.0 -e \ moqsrc name=s url=https://cdn.moq.dev/demo broadcast=bbb.hang \ s.video_0 ! queue ! decodebin3 ! videoconvert ! autovideosink \ s.audio_0 ! queue ! decodebin3 ! audioconvert ! autoaudiosink diff --git a/doc/setup/install.md b/doc/setup/install.md index 83a5e9ce0f..616370aa2d 100644 --- a/doc/setup/install.md +++ b/doc/setup/install.md @@ -32,9 +32,9 @@ cargo install moq-relay moq-cli # Homebrew (macOS and Linux) brew install moq-dev/tap/moq-relay moq-dev/tap/moq -# Nix (pin a release tag to use the binary cache) -nix run github:moq-dev/moq#moq-relay -- relay.toml -nix run github:moq-dev/moq#moq -- --help +# Nix (`release` builds from source; a release tag such as moq-relay-v0.12.4 uses the binary cache) +nix run github:moq-dev/moq/release#moq-relay -- relay.toml +nix run github:moq-dev/moq/release#moq -- --help # Docker (linux/amd64 and linux/arm64) docker run -p 4443:4443/udp -p 4443:4443/tcp -v "$PWD/relay.toml:/app/relay.toml:ro" moqdev/moq-relay /app/relay.toml @@ -44,8 +44,8 @@ docker run -i moqdev/moq --help Static binaries for Linux (x86\_64, aarch64), macOS (Apple Silicon), and Windows (x64) are attached to every [GitHub release](https://github.com/moq-dev/moq/releases). The Nix cache at -`kixelated.cachix.org` only holds tagged releases, so an unpinned -`github:moq-dev/moq` builds from source. +`kixelated.cachix.org` only holds tagged releases, so the `release` branch +builds from source. ## Debian and Ubuntu diff --git a/flake.nix b/flake.nix index c537b778b2..e0ac5182e2 100644 --- a/flake.nix +++ b/flake.nix @@ -447,7 +447,7 @@ # The package was `moq-cli` through 0.12.2. Refuse with the new name # so `nix run` and `nix profile upgrade` break instead of going stale. moq-cli = pkgs.writeShellScriptBin "moq" '' - echo "error: the moq-cli package is now moq: nix run github:moq-dev/moq#moq" >&2 + echo "error: the moq-cli package is now moq: nix run github:moq-dev/moq/release#moq" >&2 exit 1 ''; diff --git a/rs/moq-gst/README.md b/rs/moq-gst/README.md index ffd42f952c..6e1ec211a7 100644 --- a/rs/moq-gst/README.md +++ b/rs/moq-gst/README.md @@ -36,21 +36,21 @@ sudo dnf install gstreamer1-moq The `moq-gst` flake output bundles the plugin with wrappers around `gst-inspect-1.0` / `gst-launch-1.0` that preload moq + the standard `gst-plugins-{base,good,bad}` set, so no `GST_PLUGIN_PATH` setup is needed. ```bash -# Inspect: list moqsink + moqsrc. (Or one-shot: `nix run github:moq-dev/moq#moq-gst -- moq`.) -nix shell github:moq-dev/moq#moq-gst --command gst-inspect-1.0 moq +# Inspect: list moqsink + moqsrc. (Or one-shot: `nix run github:moq-dev/moq/release#moq-gst -- moq`.) +nix shell github:moq-dev/moq/release#moq-gst --command gst-inspect-1.0 moq # Subscribe to the always-on public test broadcast and render to a window. # moqsrc emits one pad per rendition (video_0, audio_0, ...); link the one(s) you # want by name. A bare `moqsrc ! decodebin3 ! ...` only links the first pad offered, # which on a video+audio broadcast may be the audio pad (so a video sink shows nothing). -nix shell github:moq-dev/moq#moq-gst --command gst-launch-1.0 -v -e \ +nix shell github:moq-dev/moq/release#moq-gst --command gst-launch-1.0 -v -e \ moqsrc name=s url=https://cdn.moq.dev/demo broadcast=bbb.hang \ s.video_0 ! queue ! decodebin3 ! videoconvert ! autovideosink \ s.audio_0 ! queue ! decodebin3 ! audioconvert ! autoaudiosink # Publish your own broadcast on the public anon relay (then sub to it from anywhere). curl -fsSL https://vid.moq.dev/bbb.mp4 -o bbb.mp4 -nix shell github:moq-dev/moq#moq-gst --command gst-launch-1.0 -v -e \ +nix shell github:moq-dev/moq/release#moq-gst --command gst-launch-1.0 -v -e \ multifilesrc location=bbb.mp4 loop=true ! parsebin name=parse \ parse. ! queue ! identity sync=true ! mux.sink_0 \ parse. ! queue ! identity sync=true ! mux.sink_1 \ diff --git a/sh/gh/back-merge.sh b/sh/gh/back-merge.sh new file mode 100755 index 0000000000..3021f3e318 --- /dev/null +++ b/sh/gh/back-merge.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# Open (or refresh) the pull request that merges `release` back into `main`, +# and queue it to land as a merge commit once Check and Test pass. +# +# The pull request's head is its own branch rather than `release` itself: the +# repository deletes head branches on merge, and a conflict is resolved by +# merging `main` into that branch, which must never touch `release`. +# +# Required env: +# GH_TOKEN A token whose pull requests trigger CI (not GITHUB_TOKEN). +# GITHUB_REPOSITORY owner/repo, set by GitHub Actions. + +set -euo pipefail + +repo="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY must be set}" +branch=merge/release-into-main + +ahead=$(gh api "repos/$repo/compare/main...release" --jq .ahead_by) +if [[ "$ahead" == 0 ]]; then + echo "main already contains release" + exit 0 +fi + +sha=$(gh api "repos/$repo/git/ref/heads/release" --jq .object.sha) + +# --exit-code: 2 means the branch is absent; anything else nonzero is a failure. +status=0 +git ls-remote --exit-code --heads "https://github.com/$repo" "$branch" >/dev/null || status=$? +case "$status" in + 0) + # An open pull request may carry a hand-resolved conflict, so merge into the + # branch instead of resetting it. A conflict fails here, loudly. + gh api -X POST "repos/$repo/merges" -f base="$branch" -f head="$sha" \ + -f commit_message="Merge release into $branch" >/dev/null + ;; + 2) + gh api -X POST "repos/$repo/git/refs" -f ref="refs/heads/$branch" -f sha="$sha" >/dev/null + ;; + *) + echo "error: cannot query $branch on $repo" >&2 + exit 1 + ;; +esac + +# Only a pull request from this repository's own branch: `gh pr list --head` +# matches the branch name alone, so a fork's branch of the same name would pass. +find_pr() { + gh api "repos/$repo/pulls?state=open&base=main&head=${repo%%/*}:$branch" \ + --jq "[.[] | select(.head.repo.full_name == \"$repo\")][0].number // empty" +} + +pr=$(find_pr) +if [[ -z "$pr" ]]; then + gh pr create --repo "$repo" --head "$branch" --base main \ + --title "chore: merge release into main" \ + --body "Carries what \`release\` published (versions, CHANGELOGs, backports) back to trunk. Opened by the Back-merge workflow. + +**Merge with a merge commit. Never squash.** A squash leaves the merge base at the last cut, so the next back-merge conflicts. On a conflict, merge \`main\` into \`$branch\` and resolve it there." + pr=$(find_pr) +fi +if [[ -z "$pr" ]]; then + echo "error: no pull request from $repo:$branch into main" >&2 + exit 1 +fi + +# Queue exactly the commit this run produced, so a later push cannot ride along. +head=$(gh api "repos/$repo/git/ref/heads/$branch" --jq .object.sha) +gh pr merge --repo "$repo" "$pr" --auto --merge --match-head-commit "$head"