ci: move release flow into a GitHub Actions workflow#2417
Conversation
release.sh becomes a thin dispatcher that validates the version and triggers the new Release workflow via gh. The workflow refreshes the PGO profile, bumps the Caddy module, commits as github-actions[bot], tags, pushes, drafts the release, and bumps the Homebrew formula. The standalone pgo-profile workflow is removed: its job is folded into the release workflow, so every release commit carries a fresh profile.
There was a problem hiding this comment.
Pull request overview
Moves the FrankenPHP release process from a developer-run shell script into a GitHub Actions workflow. release.sh becomes a thin dispatcher (semver validation + gh workflow run), the new release.yaml performs PGO refresh, Caddy module bump, commit/tag/push, draft GitHub release, and Homebrew formula bump on a release environment, and the standalone pgo-profile.yaml workflow is removed in favor of generating a fresh PGO profile per release.
Changes:
- Replace local-machine release script with a
gh workflow rundispatcher and tag-existence pre-check. - Add
.github/workflows/release.yamldoing end-to-end release work asgithub-actions[bot]. - Delete
.github/workflows/pgo-profile.yamland gitignore the intermediateprofiles/{regular,worker}.pgofiles.
Reviewed changes
Copilot reviewed 3 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| release.sh | Reduced to semver validation, tag pre-check, and gh workflow run release.yaml. |
| .github/workflows/release.yaml | New end-to-end release workflow (PGO refresh, bump, commit, tag, push, draft release, Homebrew PR). |
| .github/workflows/pgo-profile.yaml | Removed; PGO refresh is now part of the release workflow. |
| .gitignore | Ignores profiles/regular.pgo and profiles/worker.pgo intermediates. |
Comments suppressed due to low confidence (6)
.github/workflows/release.yaml:74
workflow_dispatchcan be triggered from any branch (the workflow file itself just needs to exist on the default branch, but the run executes against the dispatched ref). Because there is no guard verifying that the checked-out HEAD ismain(or that it equalsorigin/main), dispatching this workflow from a feature branch will causegit push --follow-tags origin HEAD:mainto push that branch's tip ontomainand tag it asv<version>. The previous shell script explicitly verifiedgit branch --show-current == mainand that local main matchedorigin/main; that pre-flight protection has been lost in the move to CI. Consider adding an early step that fails if${{ github.ref }}is notrefs/heads/main, and/or fast-forwarding toorigin/mainbefore tagging.
- name: Commit, tag, push
env:
VERSION: ${{ inputs.version }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add caddy/frankenphp/default.pgo caddy/go.mod caddy/go.sum
git commit -m "chore: prepare release ${VERSION}"
git tag -a -m "Version ${VERSION}" "v${VERSION}"
git tag -a -m "Version ${VERSION}" "caddy/v${VERSION}"
git push --follow-tags origin HEAD:main
.github/workflows/release.yaml:73
- The previous release flow used
git commit -Sandgit tag -sto produce GPG-signed release commits and signed annotated tags. The new workflow replaces these with plaingit commitandgit tag -a, so release tags will no longer be cryptographically signed andgh release create --verify-tagwill only verify the tag exists, not that it is signed. If signed tags are an expected provenance signal for downstream consumers (Homebrew, Docker tags, distros), this is a regression. If the intent is to drop signing in favor of GitHub's commit attestation / actions identity, that decision is worth calling out explicitly in the workflow comments.
git commit -m "chore: prepare release ${VERSION}"
git tag -a -m "Version ${VERSION}" "v${VERSION}"
git tag -a -m "Version ${VERSION}" "caddy/v${VERSION}"
.github/workflows/release.yaml:90
- The semver regex on line 23 (unchanged) accepts pre-release identifiers such as
1.5.0-rc1or2.0.0-beta.1, but the workflow unconditionally invokesgh release create ... --latestandbrew bump-formula-pr ... --version "${VERSION}". Dispatching a pre-release version would therefore mark the pre-release as the "latest" GitHub release and open a Homebrew PR pointing the stable formula at it. Consider detecting a-in the version and either skipping--latest/ the Homebrew bump for pre-releases, or rejecting pre-release inputs inrelease.sh.
gh release create \
--draft --generate-notes --latest \
--notes-start-tag "${previous_tag}" \
--verify-tag "v${VERSION}"
- uses: Homebrew/actions/setup-homebrew@master
- name: Bump Homebrew formula
env:
HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_TOKEN }}
VERSION: ${{ inputs.version }}
run: brew bump-formula-pr dunglas/frankenphp/frankenphp --version "${VERSION}" --no-browse
.github/workflows/release.yaml:84
previous_tagis derived by sorting allv*tags by version and taking the second entry. This runs after the newv${VERSION}tag has been created locally (line 72), so it relies on the new tag being position 1. That works when${VERSION}is the highest semver, but for a back-port / patch on an older minor (e.g. cutting1.4.3after1.5.0already exists),--sort=-version:refnamewill placev1.5.0first and the just-createdv1.4.3second, makingprevious_tagequal tov1.5.0andgh release createeither fail (--notes-start-tagnewer than--verify-tag) or generate empty/wrong notes. Consider deriving the previous tag fromgit describe --tags --abbrev=0 "v${VERSION}^"or otherwise restricting to ancestors of the new tag.
previous_tag=$(git tag --list --sort=-version:refname 'v*' | awk 'NR==2 {print;exit}')
gh release create \
--draft --generate-notes --latest \
--notes-start-tag "${previous_tag}" \
--verify-tag "v${VERSION}"
.github/workflows/release.yaml:71
git add caddy/frankenphp/default.pgo caddy/go.mod caddy/go.sumfollowed bygit commitwill fail withnothing to commit(and abort the workflow because ofset -eimplicit inrun:blocks) ifbuild-pgo.shproduced no diff indefault.pgoandgo getwas a no-op (e.g. the Caddy module was already pinned to that version, which can happen when re-running after a partial failure). Consider adding--allow-emptyor guarding withgit diff --cached --quiet || git commit ..., mirroring the pattern used in the deletedpgo-profile.yaml(git diff --cached --quiet && exit 0).
git add caddy/frankenphp/default.pgo caddy/go.mod caddy/go.sum
git commit -m "chore: prepare release ${VERSION}"
release.sh:28
gh api "repos/:owner/:repo/git/refs/tags/v$1"uses the legacy:owner/:repoplaceholder syntax. The documentedgh apiplaceholder syntax is{owner}/{repo}(and only resolves when running inside a git repo whose remote points to the target). Sincerelease.shis invoked locally on the maintainer's machine, this should still work today, but the workflow on line 41 (repos/${GITHUB_REPOSITORY}/...) uses the explicit env var. Consider usingrepos/{owner}/{repo}/...here for consistency and to be robust against the maintainer running this from a checkout whose remote name isn'torigin.
if gh api "repos/:owner/:repo/git/refs/tags/v$1" --silent 2>/dev/null; then
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Replace git commit/push/tag with REST API calls. API-created commits and annotated tags are signed server-side with GitHub's key and show as "Verified" under the github-actions[bot] identity.
- Skip CKV_GHA_7 (workflow_dispatch inputs): the version is a release identifier, not user-controlled build configuration. - Guard the job with `if: github.ref == 'refs/heads/main'` so dispatch from a feature branch can't push that branch to main. - Pre-check both `v<version>` and `caddy/v<version>` so a stale caddy/v* tag fails fast before the PGO refresh. - Detect pre-release versions (semver `-suffix`) and drop `--latest` + skip the Homebrew bump for them. - Derive previous tag from `git describe ... v<version>^` so back-port releases (e.g. v1.4.3 after v1.5.0) get correct release notes. - Swap brew bump-formula-pr for mislav/bump-homebrew-formula-action: one step instead of two, no brew install required.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 4 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (4)
.github/workflows/release.yaml:152
git describe ... "v${VERSION}^"requires the newv${VERSION}tag (and its underlying commit object) to be present locally. The preceding step creates the commit and tag entirely through the GitHub REST API, so the local working copy still points at the originalmainHEAD and does not contain the new commit object.git fetch --tags origin(line 143) will fetch the tag refs, but if the new commit hasn't been advertised on a fetched branch yet, some git versions / fetch configurations won't download the dangling commit pointed to by the new annotated tag, in which casev${VERSION}^cannot be resolved and this step fails. It is safer to also fetchorigin/mainexplicitly (e.g.git fetch origin main --tags) and/or computeprevious_tagdirectly viagh apiagainst the parent SHA you already know ($parent_sha) instead of relying on local git state.
# Sync local tags so the release-draft step can compute the previous tag.
git fetch --tags origin
- name: Draft GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
PRERELEASE: ${{ steps.classify.outputs.prerelease }}
# Use the parent commit of the new tag to pick the previous tag so
# back-port releases (e.g. v1.4.3 after v1.5.0) get correct notes.
run: |
previous_tag=$(git describe --tags --abbrev=0 --match 'v*' --exclude "v${VERSION}" "v${VERSION}^")
.github/workflows/release.yaml:76
go install github.com/google/pprof@lateston every release pulls a non-pinned version of pprof. This is only used for a diagnostic|| truesummary, but it still couples release reproducibility to whatever HEAD of pprof exists at release time and silently introduces a third-party tool with no version pinning into a workflow that hascontents: write. Pin to a specific version (e.g.@v0.0.0-…) or drop the step.
- name: Show pprof summary
run: |
go install github.com/google/pprof@latest
"$(go env GOPATH)/bin/pprof" -top -cum -nodecount=25 caddy/frankenphp/default.pgo || true
.github/workflows/release.yaml:162
mislav/bump-homebrew-formula-action@v3is referenced by a mutable major-version tag. Other third-party actions in this workflow may follow the same pattern, but for a release workflow that holds a PAT with write access to an external repo (HOMEBREW_TOKEN), pinning to a full commit SHA is the standard supply-chain hardening recommendation (and matches whatzizmor/checkovwill flag). Optional but recommended.
uses: mislav/bump-homebrew-formula-action@v3
release.sh:35
- The previous
release.shenforced that the working tree was clean and in sync withorigin/mainbefore tagging. The new dispatcher drops those checks entirely, so a developer can run./release.sh 1.5.0from a stale or dirty local checkout and trigger a real release without warning. Since the dispatcher's only remaining responsibilities are validation, consider re-adding the simple "are you on main / is origin up to date" guards (they cost nothing locally) so the script still fails fast for the common operator mistake of running it from the wrong branch.
if gh api "repos/:owner/:repo/git/refs/tags/v$1" --silent 2>/dev/null; then
echo "Tag v$1 already exists on origin." >&2
exit 1
fi
gh workflow run release.yaml -f version="$1"
echo "Release workflow dispatched for v$1."
echo "Follow progress with: gh run watch \$(gh run list --workflow=release.yaml --limit=1 --json databaseId -q '.[0].databaseId')"
- Fix release.sh tag pre-check: gh api only honors `{owner}/{repo}`,
not `:owner/:repo`; the old form 404'd silently and the check was
a no-op.
- Drop the job-level `if: github.ref == 'refs/heads/main'` (which
reports as green-skipped on mis-dispatch) for a first-step guard
that errors loudly.
- Fetch `origin main --tags` explicitly so `git describe v<ver>^`
can resolve the parent commit pointed at by the new annotated tag.
- Re-add cheap operator-side guards in release.sh: must be on main,
working tree clean. Pass `--ref main` to gh workflow run.
- Drop the pprof @latest diagnostic step (unpinned third-party
binary in a workflow with contents:write).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 4 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (4)
release.sh:46
gh run list --limit=1will return whatever the most recent run is, which can race with other workflows writing runs at dispatch time (and the dispatched run may not even be visible yet by the time the operator copies/pastes this command). Consider filtering by--event workflow_dispatchand/or usinggh workflow run --ref main ... && sleepplusgh run list --workflow=release.yaml --user @meso the printed hint reliably resolves to the run just dispatched.
echo "Follow progress with: gh run watch \$(gh run list --workflow=release.yaml --limit=1 --json databaseId -q '.[0].databaseId')"
.github/workflows/release.yaml:127
- The
Commit and tag via GitHub APIstep PATCHesrefs/heads/maindirectly with the defaultGITHUB_TOKEN. If branch protection rules onmainrequire status checks, a PR, or signed commits from a specific identity, this update will be rejected and the release will fail mid-flight (after the PGO refresh has already run, but before tags exist). At minimum the workflow should be tested against the actual branch protection configuration; alternatively, consider gating the push behind a step that verifies the token is allowed to bypass protections, or push from a feature branch + auto-merge.
- name: Commit and tag via GitHub API
# API-created commits/tags are signed server-side with GitHub's key
# and show as "Verified" under the github-actions[bot] identity.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
make_blob() {
jq -nc --arg content "$(base64 -w0 <"$1")" \
'{content: $content, encoding: "base64"}' \
| gh api "repos/${REPO}/git/blobs" --input - -q .sha
}
parent_sha=$(gh api "repos/${REPO}/git/refs/heads/main" -q .object.sha)
base_tree=$(gh api "repos/${REPO}/git/commits/${parent_sha}" -q .tree.sha)
pgo_sha=$(make_blob caddy/frankenphp/default.pgo)
gomod_sha=$(make_blob caddy/go.mod)
gosum_sha=$(make_blob caddy/go.sum)
tree_sha=$(jq -nc \
--arg base_tree "$base_tree" \
--arg pgo "$pgo_sha" \
--arg gomod "$gomod_sha" \
--arg gosum "$gosum_sha" \
'{
base_tree: $base_tree,
tree: [
{path: "caddy/frankenphp/default.pgo", mode: "100644", type: "blob", sha: $pgo},
{path: "caddy/go.mod", mode: "100644", type: "blob", sha: $gomod},
{path: "caddy/go.sum", mode: "100644", type: "blob", sha: $gosum}
]
}' | gh api "repos/${REPO}/git/trees" --input - -q .sha)
commit_sha=$(jq -nc \
--arg message "chore: prepare release ${VERSION}" \
--arg tree "$tree_sha" \
--arg parent "$parent_sha" \
'{message: $message, tree: $tree, parents: [$parent]}' \
| gh api "repos/${REPO}/git/commits" --input - -q .sha)
gh api "repos/${REPO}/git/refs/heads/main" -X PATCH -f sha="$commit_sha" --silent
.github/workflows/release.yaml:141
- Pushing the release commit through the GitHub API as
github-actions[bot]usingGITHUB_TOKENwill not trigger downstream workflows on the resultingpushtomainor on the new tag (this is GitHub's documented behavior to prevent recursion). The previous setup created the commit via localgit pushfrom a user-authenticatedghCLI, so any tag-triggered workflows (e.g.static.yaml/docker.yaml/windows.yamlthat build ontagrefs) would fire. Verify whether the release pipeline depends on tag-push triggers; if it does, switch to a PAT (or GitHub App token) to push the commit/tag, or invoke the downstream workflows explicitly viaworkflow_dispatch.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
make_blob() {
jq -nc --arg content "$(base64 -w0 <"$1")" \
'{content: $content, encoding: "base64"}' \
| gh api "repos/${REPO}/git/blobs" --input - -q .sha
}
parent_sha=$(gh api "repos/${REPO}/git/refs/heads/main" -q .object.sha)
base_tree=$(gh api "repos/${REPO}/git/commits/${parent_sha}" -q .tree.sha)
pgo_sha=$(make_blob caddy/frankenphp/default.pgo)
gomod_sha=$(make_blob caddy/go.mod)
gosum_sha=$(make_blob caddy/go.sum)
tree_sha=$(jq -nc \
--arg base_tree "$base_tree" \
--arg pgo "$pgo_sha" \
--arg gomod "$gomod_sha" \
--arg gosum "$gosum_sha" \
'{
base_tree: $base_tree,
tree: [
{path: "caddy/frankenphp/default.pgo", mode: "100644", type: "blob", sha: $pgo},
{path: "caddy/go.mod", mode: "100644", type: "blob", sha: $gomod},
{path: "caddy/go.sum", mode: "100644", type: "blob", sha: $gosum}
]
}' | gh api "repos/${REPO}/git/trees" --input - -q .sha)
commit_sha=$(jq -nc \
--arg message "chore: prepare release ${VERSION}" \
--arg tree "$tree_sha" \
--arg parent "$parent_sha" \
'{message: $message, tree: $tree, parents: [$parent]}' \
| gh api "repos/${REPO}/git/commits" --input - -q .sha)
gh api "repos/${REPO}/git/refs/heads/main" -X PATCH -f sha="$commit_sha" --silent
create_tag() {
local tag_sha
tag_sha=$(jq -nc \
--arg tag "$1" \
--arg message "Version ${VERSION}" \
--arg object "$commit_sha" \
'{tag: $tag, message: $message, object: $object, type: "commit"}' \
| gh api "repos/${REPO}/git/tags" --input - -q .sha)
gh api "repos/${REPO}/git/refs" -f ref="refs/tags/$1" -f sha="$tag_sha" --silent
}
create_tag "v${VERSION}"
create_tag "caddy/v${VERSION}"
.github/workflows/release.yaml:161
- If the bash variable
previous_tagis empty (e.g. the very first release, orgit describefailing for any reason),gh release createwould still be invoked with--notes-start-tag "", which is at best meaningless and at worst causes a confusing failure. Becauseset -eis on by default for GitHub Actions bash, a non-zerogit describeshould abort the step, but that aborts the entire release after the tag has already been created, leaving the repo in a half-released state. Consider validatingprevious_tagis non-empty before callinggh release create, or moving the release-draft step to run before the tags are created so a failure here is recoverable.
run: |
previous_tag=$(git describe --tags --abbrev=0 --match 'v*' --exclude "v${VERSION}" "v${VERSION}^")
args=(--draft --generate-notes --notes-start-tag "${previous_tag}" --verify-tag "v${VERSION}")
if [[ "${PRERELEASE}" == "true" ]]; then
args+=(--prerelease)
else
args+=(--latest)
fi
gh release create "v${VERSION}" "${args[@]}"
- Drop the local tag pre-check from release.sh (the workflow already enforces it; YAGNI per maintainer). - Set persist-credentials: false on the checkout — commits/tags now go through the REST API, no git push remains in this workflow. - Run `go mod tidy` after `go get` so the release commit doesn't leave caddy/go.sum in a state the tests workflow rejects. - Make previous_tag detection defensive for first releases and any git describe failure — drop --notes-start-tag if empty. - Trigger static/docker/windows workflows explicitly via gh workflow run since GITHUB_TOKEN-driven API writes don't fire tag/push workflows. Adds actions:write permission. - Filter the release.sh run-list hint by event + user to avoid racing with other dispatches.
GITHUB_TOKEN-driven pushes already don't fire downstream workflows, but this is a defensive belt-and-suspenders so a future switch to a PAT (or anyone replaying the commit) doesn't double-trigger the static / docker / windows builds we already dispatch explicitly.
| @@ -46,32 +36,6 @@ if [[ -n "$(git status --porcelain)" ]]; then | |||
| exit 1 | |||
| fi | |||
|
|
|||
| git fetch origin | |||
| local_head="$(git rev-parse HEAD)" | |||
| remote_head="$(git rev-parse origin/main)" | |||
| if [[ "$local_head" != "$remote_head" ]]; then | |||
| if git merge-base --is-ancestor HEAD origin/main; then | |||
| echo "Local main is behind origin/main. Pull first." >&2 | |||
| elif git merge-base --is-ancestor origin/main HEAD; then | |||
| echo "Local main is ahead of origin/main. Push your commits or reset to origin/main before releasing." >&2 | |||
| else | |||
| echo "Local main has diverged from origin/main. Reconcile with pull/rebase/reset before releasing." >&2 | |||
| fi | |||
| exit 1 | |||
| fi | |||
|
|
|||
| cd caddy/ | |||
| go get "github.com/dunglas/frankenphp@v$1" | |||
| cd - | |||
|
|
|||
| git commit -S -a -m "chore: prepare release $1" || echo "skip" | |||
|
|
|||
| git tag -s -m "Version $1" "v$1" | |||
| git tag -s -m "Version $1" "caddy/v$1" | |||
| git push --follow-tags | |||
|
|
|||
| tags=$(git tag --list --sort=-version:refname 'v*') | |||
| previous_tag=$(awk 'NR==2 {print;exit}' <<<"${tags}") | |||
|
|
|||
| gh release create --draft --generate-notes --latest --notes-start-tag "${previous_tag}" --verify-tag "v$1" | |||
| brew bump-formula-pr dunglas/frankenphp/frankenphp --version "$1" | |||
| gh workflow run release.yaml --ref main -f version="$1" | |||
| echo "Release workflow dispatched for v$1." | |||
| echo "Follow progress with: gh run watch \$(gh run list --workflow=release.yaml --event=workflow_dispatch --user=@me --limit=1 --json databaseId -q '.[0].databaseId')" | |||
| gh workflow run release.yaml --ref main -f version="$1" | ||
| echo "Release workflow dispatched for v$1." | ||
| echo "Follow progress with: gh run watch \$(gh run list --workflow=release.yaml --event=workflow_dispatch --user=@me --limit=1 --json databaseId -q '.[0].databaseId')" |
| for wf in static.yaml docker.yaml windows.yaml; do | ||
| gh workflow run "${wf}" --repo "${REPO}" --ref "v${VERSION}" -f version="${VERSION}" | ||
| done |
| args=(--draft --generate-notes --verify-tag "v${VERSION}") | ||
| if [[ -n "${previous_tag}" ]]; then | ||
| args+=(--notes-start-tag "${previous_tag}") | ||
| fi | ||
| if [[ "${PRERELEASE}" == "true" ]]; then | ||
| args+=(--prerelease) | ||
| else | ||
| args+=(--latest) | ||
| fi | ||
| gh release create "v${VERSION}" "${args[@]}" |
| - name: Install wrk | ||
| run: sudo apt-get update && sudo apt-get install -y wrk && sudo apt-get install --reinstall -y libbrotli-dev | ||
| - name: Refresh PGO profile | ||
| run: ./profiles/build-pgo.sh |
| go get "github.com/dunglas/frankenphp@v${VERSION}" | ||
| go mod tidy |
| @@ -46,32 +36,6 @@ if [[ -n "$(git status --porcelain)" ]]; then | |||
| exit 1 | |||
| fi | |||
|
|
|||
- release.sh: drop the brittle gh-run-list one-liner (races with workflow_dispatch propagation; .[0].databaseId can be null). Print a stable hint instead. - release.sh: add an ahead-of-origin check so dispatching from a stale local main fails fast — the workflow runs against origin/main, not the operator's checkout. - release.yaml: keep the downstream-dispatch loop going on partial failure and surface which workflows didn't dispatch.
Summary
release.shis reduced to a thin dispatcher: validates the semver, checks the operator is on a clean localmainin sync withorigin/main, thengh workflow run release.yaml -f version=$1. Tag-existence pre-check lives in the workflow itself, not the dispatcher..github/workflows/release.yamldoes the work end-to-end: refresh PGO profile, bump the Caddy module'sfrankenphpdependency +go mod tidy, commit + tag via the GitHub REST API (Verified,github-actions[bot]), draft the release with correct previous-tag detection (handles back-ports and first releases), trigger the static / docker / windows builds explicitly (GITHUB_TOKEN-driven API writes don't fire tag/push workflows), and bump the Homebrew formula.[skip ci]as a defensive belt-and-suspenders so any future switch to PAT-driven pushes doesn't double-trigger downstream builds..github/workflows/pgo-profile.yamlis removed; its job is folded into the release workflow so every release commit carries a fresh profile..gitignoreignoresprofiles/regular.pgoandprofiles/worker.pgointermediates.Pre-reqs before first dispatch
releaseGitHub Environment.HOMEBREW_TOKEN: PAT with write access todunglas/homebrew-frankenphp.GITHUB_TOKENcovers everything else (commit/tag via REST API, draft release, downstream dispatches).