Files
PaperClipAI/scripts/release.sh
T
Devin Foley 664052f8ea feat(release): draft stable notes at beta publish, read them from master at promotion (#11567)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The release channel system promotes builds canary → nightly → beta →
stable, and stable releases publish a GitHub Release from
`releases/vYYYY.MDD.P.md`
> - The stable lane requires that notes file to exist inside the
promoted source commit, but the file is named for the promotion date,
which is unknown when the source commit is created
> - A promoted beta can therefore never pass the notes check: every
happy-path stable is forced through the candidate-branch fix path, with
a soak-gate justification, for a notes-only change
> - This pull request drafts the notes automatically when the beta is
published and lets the stable promotion read them from `master`
> - The benefit is a walkable stable happy path: the soak gate stays
exact, notes get a real review window during the soak, and the
justification path returns to its real purpose (cherry-picked fixes)

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The stable promotion path in the release channel system (`release.yml`,
`scripts/release.sh`).

**Current behavior**

`release.sh stable` requires `releases/vYYYY.MDD.P.md` in the
checked-out source tree, and `publish_stable` checks out the exact
promoted SHA. The soak gate requires a `beta/v*` tag to point at that
same SHA. No commit can satisfy both for a promoted beta, so a stable
promotion must cut a candidate branch with a notes-only commit and
bypass the soak gate with a written justification. Release notes are
also written at promotion time, under time pressure, with no review
window.

**Proposed behavior**

When a beta publishes, a `draft_stable_notes` job generates a grouped
notes skeleton at `releases/beta/v<beta-version>.md` and pushes it to a
machine-owned branch; a human opens the PR and edits it during the 3-day
soak. The stable preflight resolves notes before the `npm-stable`
approval gate: source-tree notes first (the candidate fix path,
unchanged), then the merged beta-keyed file on `master`; it fails early
with the missing path named when neither exists. After the stable ships,
a canonicalization job pushes a branch that moves the file to
`releases/vYYYY.MDD.P.md`.

Related (not duplicates): #11006 and #11008 introduced the nightly and
beta lanes this builds on; older changelog PRs (for example #10669)
authored notes manually at promotion time, which is the flow this
replaces.

**Reason and benefit**

The happy path becomes: promote the exact soaked SHA, no justification,
notes reviewed during the soak instead of written at the gate. The
`releases/vYYYY.MDD.P.md` invariant still holds durably via the
canonicalization PR.

## What Changed

- `scripts/release.sh`: new `--notes-file PATH` (stable only) overrides
where the pre-publish notes check looks, so notes can live outside the
source checkout without dirtying the worktree.
- `scripts/create-github-release.sh`: same `--notes-file` override for
the GitHub Release body.
- `scripts/draft-stable-notes.sh` (new): deterministic skeleton
generator — commit subjects from the newest stable tag (falling back to
the previous beta, then full history) to the beta's source commit,
grouped into Features / Fixes / Other.
- `.github/workflows/release.yml`:
- `draft_stable_notes` job after `publish_beta`: runs the generator and
force-pushes `release-notes/v<beta-version>`; the job summary links the
compare page. It recreates the beta tag locally if the tag push was
rejected (the known workflows-permission case), so drafting is not
blocked on manual tag recovery.
- `preflight_stable`: computes the target stable version (`release.sh
stable --print-version`) and resolves the notes source (`source_tree` →
`master_beta` → fail early / warn on dry run); new outputs.
- `publish_stable`: materializes `master`-side notes into `RUNNER_TEMP`
and passes `--notes-file` to both scripts; outputs the published stable
version.
- `canonicalize_stable_notes` job: pushes the `git mv` branch after a
stable that used `master`-side notes.
- `doc/RELEASING.md`, `doc/RELEASE-CHECKLIST.md`: document the
drafted-notes flow, the preflight resolution order, and the
canonicalization step; the LLM changelog flow now targets the draft
branch during the soak.
- `.agents/skills/release-changelog/SKILL.md`,
`.agents/skills/release-changelog-discord-message/SKILL.md`: the
notes-authoring skills now describe this flow — range ends at the beta
source commit (not `HEAD`), the file is beta-keyed on the
`release-notes/v<beta-version>` branch (seeded with
`scripts/draft-stable-notes.sh` for betas that predate the automation),
and the canonicalization link caveat is called out for announcements.

## Verification

- `node --test scripts/draft-stable-notes.test.mjs` — 6 tests, temp
git-repo fixtures: grouping, stable-tag range, previous-beta and
full-history fallbacks, default output path, malformed version, missing
tag.
- `node --test scripts/release-lib.test.mjs` — unchanged suite still
green.
- `bash -n` on both changed shell scripts; `release.yml` re-parsed as
YAML.
- `./scripts/release.sh stable --print-version` unchanged (prints the
next stable version); `--notes-file` on a non-stable channel fails with
a clear error.
- Not exercised end-to-end: the new workflow jobs need a real beta
publish to run. The first beta after merge is the live test; the draft
job is additive and cannot affect the publish result (it runs after
`publish_beta` completes).

## Risks

- Low risk to publishing itself: `--notes-file` defaults preserve
today's behavior everywhere; the draft and canonicalization jobs are
additive and run after the publishes succeed.
- The preflight now fails a real stable run when no notes are found.
That is the intended fail-early behavior (it previously failed later,
inside `publish_stable`, after the `npm-stable` approval).
- `draft_stable_notes` force-pushes only the machine-owned
`release-notes/v<beta-version>` branch; a beta re-cut regenerates it
cleanly.
- The stable version computed at preflight could differ from the
published one if a run crosses UTC midnight between the two jobs; the
materialized notes are passed by path, so the publish still succeeds,
and the canonicalization job uses the actually-published version.

## Model Used

Claude Fable 5 (Claude Code)



## Pre-submission checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
2026-08-17 20:47:23 -07:00

433 lines
16 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=./release-lib.sh
. "$REPO_ROOT/scripts/release-lib.sh"
CLI_DIR="$REPO_ROOT/cli"
channel=""
release_date=""
dry_run=false
skip_verify=false
print_version_only=false
from_candidate=false
notes_file_override=
tag_name=""
cleanup_on_exit=false
usage() {
cat <<'EOF'
Usage:
./scripts/release.sh <canary|nightly|beta|stable> [--date YYYY-MM-DD] [--dry-run] [--skip-verify] [--print-version] [--notes-file PATH]
Examples:
./scripts/release.sh canary
./scripts/release.sh canary --date 2026-03-17 --dry-run
./scripts/release.sh nightly --dry-run
./scripts/release.sh beta --dry-run
./scripts/release.sh beta --from-candidate --dry-run
./scripts/release.sh stable
./scripts/release.sh stable --date 2026-03-17 --dry-run
./scripts/release.sh stable --date 2026-03-18 --print-version
Notes:
- Stable versions use YYYY.MDD.P, where M is the UTC month, DD is the
zero-padded UTC day, and P is the same-day stable patch slot.
- Canary releases publish YYYY.MDD.P-canary.N under the npm dist-tag
"canary" and create the git tag canary/vYYYY.MDD.P-canary.N.
- Nightly releases republish a commit that already shipped a canary (HEAD
must carry a canary/v* tag) as YYYY.MDD.P-nightly.N under the npm
dist-tag "nightly", with the git tag nightly/vYYYY.MDD.P-nightly.N.
The version dates the nightly cut, not the source canary.
- Beta releases republish a commit that already shipped a nightly (HEAD
must carry a nightly/v* tag) as YYYY.MDD.P-beta.N under the npm
dist-tag "beta", with the git tag beta/vYYYY.MDD.P-beta.N.
- --from-candidate (beta only) waives the nightly-tag requirement for
cherry-picked candidate-branch builds; callers are responsible for
validating the candidate branch before using it.
- Stable releases publish YYYY.MDD.P under the npm dist-tag "latest" and
create the git tag vYYYY.MDD.P.
- Non-dry-run stable release notes must already exist at releases/vYYYY.MDD.P.md,
or be supplied explicitly with --notes-file (stable only). The override
exists so promotions can read notes maintained on master instead of
requiring them inside the promoted source commit's tree.
- The script rewrites versions temporarily and restores the working tree on
exit. Tags always point at the original source commit, not a generated
release commit.
EOF
}
restore_publish_artifacts() {
if [ -f "$CLI_DIR/package.dev.json" ]; then
mv "$CLI_DIR/package.dev.json" "$CLI_DIR/package.json"
fi
rm -f "$CLI_DIR/README.md"
rm -rf "$REPO_ROOT/server/ui-dist"
for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do
rm -rf "$REPO_ROOT/$pkg_dir/skills"
done
}
cleanup_release_state() {
restore_publish_artifacts
tracked_changes="$(git -C "$REPO_ROOT" diff --name-only; git -C "$REPO_ROOT" diff --cached --name-only)"
if [ -n "$tracked_changes" ]; then
printf '%s\n' "$tracked_changes" | sort -u | while IFS= read -r path; do
[ -z "$path" ] && continue
git -C "$REPO_ROOT" checkout -q HEAD -- "$path" || true
done
fi
untracked_changes="$(git -C "$REPO_ROOT" ls-files --others --exclude-standard)"
if [ -n "$untracked_changes" ]; then
printf '%s\n' "$untracked_changes" | while IFS= read -r path; do
[ -z "$path" ] && continue
if [ -d "$REPO_ROOT/$path" ]; then
rm -rf "$REPO_ROOT/$path"
else
rm -f "$REPO_ROOT/$path"
fi
done
fi
}
set_cleanup_trap() {
cleanup_on_exit=true
trap cleanup_release_state EXIT
}
while [ $# -gt 0 ]; do
case "$1" in
canary|nightly|beta|stable)
if [ -n "$channel" ]; then
release_fail "only one release channel may be provided."
fi
channel="$1"
;;
--date)
shift
[ $# -gt 0 ] || release_fail "--date requires YYYY-MM-DD."
release_date="$1"
;;
--dry-run) dry_run=true ;;
--skip-verify) skip_verify=true ;;
--print-version) print_version_only=true ;;
--from-candidate) from_candidate=true ;;
--notes-file)
shift
[ $# -gt 0 ] || release_fail "--notes-file requires a path."
notes_file_override="$1"
;;
-h|--help)
usage
exit 0
;;
*)
release_fail "unexpected argument: $1"
;;
esac
shift
done
[ -n "$channel" ] || {
usage
exit 1
}
if [ "$from_candidate" = true ] && [ "$channel" != "beta" ]; then
release_fail "--from-candidate only applies to the beta channel."
fi
if [ -n "$notes_file_override" ] && [ "$channel" != "stable" ]; then
release_fail "--notes-file only applies to the stable channel."
fi
PUBLISH_REMOTE="$(resolve_release_remote)"
fetch_release_remote "$PUBLISH_REMOTE"
CURRENT_BRANCH="$(git_current_branch)"
CURRENT_SHA="$(git -C "$REPO_ROOT" rev-parse HEAD)"
LAST_STABLE_TAG="$(get_last_stable_tag)"
CURRENT_STABLE_VERSION="$(get_current_stable_version)"
RELEASE_DATE="${release_date:-$(utc_date_iso)}"
PUBLIC_PACKAGE_INFO="$(list_public_package_info)"
PUBLIC_PACKAGE_NAMES=()
while IFS= read -r package_name; do
[ -n "$package_name" ] || continue
PUBLIC_PACKAGE_NAMES+=("$package_name")
done < <(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f2)
[ -n "$PUBLIC_PACKAGE_INFO" ] || release_fail "no public packages were found in the workspace."
# Pre-fetch published versions for every public package in parallel so the
# version helpers below do not each issue one serial `npm view` call per
# package (see scripts/release-registry-versions.mjs).
RELEASE_PACKAGE_VERSIONS_FILE="$(mktemp)"
export RELEASE_PACKAGE_VERSIONS_FILE
node "$REPO_ROOT/scripts/release-registry-versions.mjs" fetch "${PUBLIC_PACKAGE_NAMES[@]}" > "$RELEASE_PACKAGE_VERSIONS_FILE"
TARGET_STABLE_VERSION="$(next_stable_version "$RELEASE_DATE" "${PUBLIC_PACKAGE_NAMES[@]}")"
TARGET_PUBLISH_VERSION="$TARGET_STABLE_VERSION"
DIST_TAG="latest"
if [ "$channel" = "canary" ]; then
require_on_master_branch
TARGET_PUBLISH_VERSION="$(next_prerelease_version canary "$TARGET_STABLE_VERSION" "${PUBLIC_PACKAGE_NAMES[@]}")"
DIST_TAG="canary"
tag_name="$(prerelease_tag_name canary "$TARGET_PUBLISH_VERSION")"
elif [ "$channel" = "nightly" ]; then
# Nightly promotes an already-shipped canary commit, so it runs from a
# detached checkout of that commit rather than the master branch tip.
require_channel_tag_at_head canary
require_channel_tag_absent_at_head nightly
TARGET_PUBLISH_VERSION="$(next_prerelease_version nightly "$TARGET_STABLE_VERSION" "${PUBLIC_PACKAGE_NAMES[@]}")"
DIST_TAG="nightly"
tag_name="$(prerelease_tag_name nightly "$TARGET_PUBLISH_VERSION")"
elif [ "$channel" = "beta" ]; then
if [ "$from_candidate" = true ]; then
# Candidate builds carry targeted cherry-picks that never shipped as a
# nightly, so the nightly-tag requirement does not apply; the workflow
# validates the candidate branch identity before invoking this path.
:
else
# Beta promotes an already-shipped nightly commit.
require_channel_tag_at_head nightly
fi
require_channel_tag_absent_at_head beta
TARGET_PUBLISH_VERSION="$(next_prerelease_version beta "$TARGET_STABLE_VERSION" "${PUBLIC_PACKAGE_NAMES[@]}")"
DIST_TAG="beta"
tag_name="$(prerelease_tag_name beta "$TARGET_PUBLISH_VERSION")"
else
tag_name="$(stable_tag_name "$TARGET_STABLE_VERSION")"
fi
rm -f "$RELEASE_PACKAGE_VERSIONS_FILE"
unset RELEASE_PACKAGE_VERSIONS_FILE
if [ "$print_version_only" = true ]; then
printf '%s\n' "$TARGET_PUBLISH_VERSION"
exit 0
fi
NOTES_FILE="$(release_notes_file "$TARGET_STABLE_VERSION")"
if [ -n "$notes_file_override" ]; then
NOTES_FILE="$notes_file_override"
fi
require_clean_worktree
require_npm_publish_auth "$dry_run"
if [ "$channel" = "stable" ] && [ "$dry_run" = false ] && [ ! -f "$NOTES_FILE" ]; then
release_fail "stable release notes file is required at $NOTES_FILE before publishing stable."
fi
if [ "$channel" = "canary" ] && [ -f "$NOTES_FILE" ]; then
release_info " ✓ Stable release notes already exist at $NOTES_FILE"
fi
if git_local_tag_exists "$tag_name" || git_remote_tag_exists "$tag_name" "$PUBLISH_REMOTE"; then
release_fail "git tag $tag_name already exists locally or on $PUBLISH_REMOTE."
fi
# Fresh (non-cached) existence check, batched in parallel. Prints the
# offending package@version pairs itself before failing.
node "$REPO_ROOT/scripts/release-registry-versions.mjs" assert-absent "$TARGET_PUBLISH_VERSION" "${PUBLIC_PACKAGE_NAMES[@]}" \
|| release_fail "npm version ${TARGET_PUBLISH_VERSION} already exists for one or more packages."
release_info ""
release_info "==> Release plan"
release_info " Remote: $PUBLISH_REMOTE"
release_info " Channel: $channel"
release_info " Current branch: ${CURRENT_BRANCH:-<detached>}"
release_info " Source commit: $CURRENT_SHA"
release_info " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
release_info " Current stable version: $CURRENT_STABLE_VERSION"
release_info " Release date (UTC): $RELEASE_DATE"
release_info " Target stable version: $TARGET_STABLE_VERSION"
case "$channel" in
canary) release_info " Canary version: $TARGET_PUBLISH_VERSION" ;;
nightly) release_info " Nightly version: $TARGET_PUBLISH_VERSION" ;;
beta) release_info " Beta version: $TARGET_PUBLISH_VERSION" ;;
*) release_info " Stable version: $TARGET_PUBLISH_VERSION" ;;
esac
release_info " Dist-tag: $DIST_TAG"
release_info " Git tag: $tag_name"
if [ "$channel" = "stable" ]; then
release_info " Release notes: $NOTES_FILE"
fi
set_cleanup_trap
# The release flow already prepares ui/dist before packaging. Reuse that output
# so server prepack does not rebuild the UI a second time during preview/publish.
export PAPERCLIP_RELEASE_REUSE_UI_DIST=1
if [ "$skip_verify" = false ]; then
release_info ""
release_info "==> Step 1/7: Verification gate..."
cd "$REPO_ROOT"
pnpm -r typecheck
pnpm test:run
pnpm build
else
release_info ""
release_info "==> Step 1/7: Verification gate skipped (--skip-verify)"
fi
release_info ""
release_info "==> Step 2/7: Building workspace artifacts..."
cd "$REPO_ROOT"
pnpm build
node "$REPO_ROOT/scripts/build-standalone-public-packages.mjs"
bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh"
for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do
rm -rf "$REPO_ROOT/$pkg_dir/skills"
cp -r "$REPO_ROOT/skills" "$REPO_ROOT/$pkg_dir/skills"
done
release_info " ✓ Workspace build complete"
release_info ""
release_info "==> Step 3/7: Rewriting workspace versions..."
set_public_package_version "$TARGET_PUBLISH_VERSION"
release_info " ✓ Versioned workspace to $TARGET_PUBLISH_VERSION"
release_info ""
release_info "==> Step 4/7: Building publishable CLI bundle..."
"$REPO_ROOT/scripts/build-npm.sh" --skip-checks --skip-typecheck
release_info " ✓ CLI bundle ready"
VERSIONED_PACKAGE_INFO="$(list_public_package_info)"
VERSION_IN_CLI_PACKAGE="$(node -e "console.log(require('$CLI_DIR/package.json').version)")"
if [ "$VERSION_IN_CLI_PACKAGE" != "$TARGET_PUBLISH_VERSION" ]; then
release_fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE."
fi
VERIFY_ATTEMPTS="${NPM_PUBLISH_VERIFY_ATTEMPTS:-12}"
VERIFY_DELAY_SECONDS="${NPM_PUBLISH_VERIFY_DELAY_SECONDS:-5}"
release_info ""
if [ "$dry_run" = true ]; then
release_info "==> Step 5/7: Previewing publish payloads (--dry-run)..."
while IFS=$'\t' read -r pkg_dir _pkg_name _pkg_version; do
[ -z "$pkg_dir" ] && continue
release_info " --- $pkg_dir ---"
cd "$REPO_ROOT/$pkg_dir"
publish_tool="$(package_publish_tool)"
if [ "$publish_tool" = "npm" ]; then
publish_dir="$(mktemp -d "${TMPDIR:-/tmp}/paperclip-release-package.XXXXXX")"
node "$REPO_ROOT/scripts/prepare-bundled-package.mjs" "$REPO_ROOT/$pkg_dir" "$publish_dir"
cd "$publish_dir"
run_bundled_npm_pack pack --pack-destination "$publish_dir" 2>&1 | tail -3
rm -rf "$publish_dir"
else
pnpm publish --dry-run --no-git-checks --tag "$DIST_TAG" 2>&1 | tail -3
fi
done <<< "$VERSIONED_PACKAGE_INFO"
release_info " [dry-run] Would create git tag $tag_name on $CURRENT_SHA"
else
release_info "==> Step 5/7: Publishing packages to npm..."
while IFS=$'\t' read -r pkg_dir pkg_name pkg_version; do
[ -z "$pkg_dir" ] && continue
release_info " Publishing $pkg_name@$pkg_version"
cd "$REPO_ROOT/$pkg_dir"
publish_tool="$(package_publish_tool)"
if [ "$publish_tool" = "npm" ]; then
publish_dir="$(mktemp -d "${TMPDIR:-/tmp}/paperclip-release-package.XXXXXX")"
node "$REPO_ROOT/scripts/prepare-bundled-package.mjs" "$REPO_ROOT/$pkg_dir" "$publish_dir"
cd "$publish_dir"
fi
if ! publish_package_to_npm_and_wait \
"$DIST_TAG" \
"$pkg_name" \
"$pkg_version" \
"$publish_tool" \
"$VERIFY_ATTEMPTS" \
"$VERIFY_DELAY_SECONDS"; then
if [ "$publish_tool" = "npm" ]; then
rm -rf "$publish_dir"
fi
release_fail "stopping release: npm did not publish and expose ${pkg_name}@${pkg_version}"
fi
if [ "$publish_tool" = "npm" ]; then
rm -rf "$publish_dir"
fi
release_info " ✓ Published version is registry-visible"
done <<< "$VERSIONED_PACKAGE_INFO"
release_info " ✓ Published the full package set under dist-tag $DIST_TAG"
fi
release_info ""
if [ "$dry_run" = true ]; then
release_info "==> Step 6/7: Skipping npm verification in dry-run mode..."
else
release_info "==> Step 6/7: Confirming npm package availability and dist-tag integrity..."
REGISTRY_STATE_VERIFY_ATTEMPTS="${NPM_REGISTRY_STATE_VERIFY_ATTEMPTS:-12}"
REGISTRY_STATE_VERIFY_DELAY_SECONDS="${NPM_REGISTRY_STATE_VERIFY_DELAY_SECONDS:-5}"
release_info " ✓ Every version was registry-visible before the next package publish"
verify_args=(
--channel "$channel"
--dist-tag "$DIST_TAG"
--target-version "$TARGET_PUBLISH_VERSION"
)
while IFS=$'\t' read -r _pkg_dir pkg_name _pkg_version; do
[ -z "$pkg_name" ] && continue
verify_args+=(--package "$pkg_name")
done <<< "$VERSIONED_PACKAGE_INFO"
release_info " Waiting for npm dist-tags and package metadata to converge..."
if wait_for_release_registry_state \
"$REGISTRY_STATE_VERIFY_ATTEMPTS" \
"$REGISTRY_STATE_VERIFY_DELAY_SECONDS" \
"${verify_args[@]}"; then
:
else
verify_status=$?
if [ "$verify_status" -eq 2 ]; then
release_fail "publish completed, but registry verification failed immediately for ${TARGET_PUBLISH_VERSION}; dist-tag state is wrong or requires operator intervention"
fi
release_fail "publish completed, but npm dist-tags or registry metadata never converged for ${TARGET_PUBLISH_VERSION}"
fi
release_info " Installing paperclipai@$DIST_TAG into a clean prefix..."
if ! verify_npm_installable "paperclipai@$DIST_TAG" "$TARGET_PUBLISH_VERSION"; then
release_fail "paperclipai@$DIST_TAG did not install cleanly at expected version ${TARGET_PUBLISH_VERSION}"
fi
release_info " ✓ Clean-prefix install resolved ${TARGET_PUBLISH_VERSION}"
fi
release_info ""
if [ "$dry_run" = true ]; then
release_info "==> Step 7/7: Dry run complete..."
else
release_info "==> Step 7/7: Creating git tag..."
git -C "$REPO_ROOT" tag "$tag_name" "$CURRENT_SHA"
release_info " ✓ Created tag $tag_name on $CURRENT_SHA"
fi
release_info ""
if [ "$dry_run" = true ]; then
release_info "Dry run complete for $channel ${TARGET_PUBLISH_VERSION}."
else
case "$channel" in
canary|nightly|beta)
release_info "Published $channel ${TARGET_PUBLISH_VERSION}."
release_info "Install with: npx paperclipai@$channel onboard"
release_info "Next step: git push ${PUBLISH_REMOTE} refs/tags/${tag_name}"
;;
*)
release_info "Published stable ${TARGET_PUBLISH_VERSION}."
release_info "Next steps:"
release_info " git push ${PUBLISH_REMOTE} refs/tags/${tag_name}"
release_info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION"
;;
esac
fi