Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ on:
description: stellar-cli version to release (e.g. 26.1.0 for a new release, or an existing version like 26.0.0 to refresh its rust pairings)
required: true
type: string
update_manifest:
description: update builds.json with fresh rust pairings; uncheck to re-trigger the publish flow for existing pairs via an empty commit
required: false
type: boolean
default: true

permissions:
contents: write
Expand Down Expand Up @@ -37,10 +42,16 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
STELLAR_CLI_VERSION: ${{ inputs.stellar_cli_version }}
REPO: ${{ github.repository }}
UPDATE_MANIFEST: ${{ inputs.update_manifest }}
run: |
manifest_flag=""
if [ "$UPDATE_MANIFEST" != "true" ]; then
manifest_flag="--skip-manifest-update"
fi
tag="$(./scripts/release_prepare.py \
--stellar-cli-version "$STELLAR_CLI_VERSION" \
--repo "$REPO")"
--repo "$REPO" \
$manifest_flag)"
Comment thread
fnando marked this conversation as resolved.
echo "release_tag=$tag" >> "$GITHUB_OUTPUT"

- name: create release branch
Expand All @@ -52,7 +63,16 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ steps.prepare.outputs.release_tag }}
REPO: ${{ github.repository }}
run: ./scripts/release_push_branch.py --release-tag "$RELEASE_TAG" --repo "$REPO"
UPDATE_MANIFEST: ${{ inputs.update_manifest }}
run: |
manifest_flag=""
if [ "$UPDATE_MANIFEST" != "true" ]; then
manifest_flag="--skip-manifest-update"
fi
./scripts/release_push_branch.py \
--release-tag "$RELEASE_TAG" \
--repo "$REPO" \
$manifest_flag

- name: open pull request
env:
Expand All @@ -63,14 +83,20 @@ jobs:
REPO: ${{ github.repository }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
UPDATE_MANIFEST: ${{ inputs.update_manifest }}
run: |
manifest_flag=""
if [ "$UPDATE_MANIFEST" != "true" ]; then
manifest_flag="--skip-manifest-update"
fi
title="$(./scripts/release_pr_body.py \
--stellar-cli-version "$VERSION" \
--release-tag "$RELEASE_TAG" \
--actor "$ACTOR" \
--repo "$REPO" \
--run-url "$RUN_URL" \
--default-branch "$DEFAULT_BRANCH" \
$manifest_flag \
--field title)"
body="$(./scripts/release_pr_body.py \
--stellar-cli-version "$VERSION" \
Expand All @@ -79,6 +105,7 @@ jobs:
--repo "$REPO" \
--run-url "$RUN_URL" \
--default-branch "$DEFAULT_BRANCH" \
$manifest_flag \
--field body)"
gh pr create \
--base "$DEFAULT_BRANCH" \
Expand Down
12 changes: 9 additions & 3 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ Docker image tags (`:<cli>-rust<key>[-<arch>]`) are unaffected by the `-N` suffi

Same workflow for both. PR review is the gate; a GitHub Release is the publish trigger. No manual tag pushes.

1. **Trigger the `release` workflow** from the Actions UI with the stellar-cli version (e.g. `26.1.0` for a brand-new release, or `26.0.0` to refresh an already-published cli with the current latest rust pairings). The workflow:
1. **Trigger the `release` workflow** from the Actions UI with the stellar-cli version (e.g. `26.1.0` for a brand-new release, or `26.0.0` to refresh an already-published cli with the current latest rust pairings).

Leave **Update manifest** checked for the normal flow. Uncheck it to re-trigger the publish flow for a cli's already-declared pairs **without** changing `builds.json` — the workflow then skips the rust auto-pick and carries the `release/<tag>` branch with an empty commit instead. Useful when you want to rebuild/republish existing pairs (e.g. after a base-image change already reflected in the pins) without adding new rust bases. Skip mode only works for a cli already declared in `builds.json`; an undeclared version is rejected up front (there would be nothing to publish).

With **Update manifest** checked, the workflow:

- Detects whether this is a **new release** (cli not yet declared) or a **refresh** (cli exists in `builds.json`).
- Picks the last two minor stable rust versions, at their latest patch each, from Docker Hub's `library/rust` tag list, filtered by the `slim-<default_distro>` suffix.
Expand All @@ -72,9 +76,11 @@ Same workflow for both. PR review is the gate; a GitHub Release is the publish t
- Picks the next available release tag — `v<version>` for a fresh release, `v<version>-<N>` for a refresh.
- Pushes a `release/<tag>` branch and opens a PR with a body modeled on stellar-cli's release PRs, including a pre-filled link to create the GitHub Release on merge.

2. **Review and adjust** the PR. The auto-pick of rust versions is a sensible default but not always right; if you want different `rust_versions` for this iteration, push commits to the release branch before merging. The PR-time `lint` and `build` workflows re-do validation and smoke-build on every push.
With **Update manifest** unchecked, the workflow skips the auto-pick, `builds.json` update, and validation; it still picks the next release tag and opens the PR, but the `release/<tag>` branch holds an empty commit instead of a `builds.json` change.

2. **Review and adjust** the PR. When the manifest was updated, the auto-pick of rust versions is a sensible default but not always right; if you want different `rust_versions` for this iteration, push commits to the release branch before merging. (In skip mode there's nothing to adjust — `builds.json` is unchanged.) The PR-time `lint` and `build` workflows re-do validation and smoke-build on every push.

3. **Merge the PR** once approved. `builds.json` now declares the new release state.
3. **Merge the PR** once approved. When the manifest was updated, `builds.json` now declares the new release state; in skip mode it is unchanged and the merge simply carries the empty commit that triggers publishing.

4. **Publish the release** by following the `Create release` link in the PR body. That opens `Releases → New release` with the tag pre-filled; add notes (or use `Generate release notes`), then **Publish release**.

Expand Down
51 changes: 43 additions & 8 deletions scripts/release_pr_body.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,28 +18,57 @@ def compose(
repo: str,
run_url: str,
default_branch: str,
skip_manifest_update: bool = False,
) -> tuple[str, str]:
iteration = "-" in release_tag.removeprefix("v")
if iteration:
if skip_manifest_update:
# Skip mode always re-publishes already-declared pairs, never adds new
# ones — so it reads as a refresh even when the tag has no -N suffix.
suffix = f" ({release_tag.removeprefix('v')})" if iteration else ""
title = f"Refresh stellar-cli {version}{suffix}"
kind = "refresh"
elif iteration:
title = f"Refresh stellar-cli {version} ({release_tag.removeprefix('v')})"
kind = "refresh"
else:
title = f"Release stellar-cli {version}"
kind = "new release"

Comment thread
fnando marked this conversation as resolved.
if skip_manifest_update:
what = (
f"Stage a {kind} for stellar-cli {version}. `builds.json` is left "
"unchanged — this carries an empty commit to re-trigger the publish "
"flow for the pairs already declared for this cli."
Comment thread
fnando marked this conversation as resolved.
)
else:
what = (
f"Stage a {kind} for stellar-cli {version}. `builds.json` is updated with "
"the rust base pins auto-picked from the current last two minor stable "
"releases on `rust-lang/rust`; each pin resolves the upstream base image "
"digest at append time (`<label>@sha256:<digest>`)."
)

if skip_manifest_update:
next_step = (
f"This branch carries an empty commit and intentionally leaves "
f"`builds.json` unchanged; push further changes to `release/{release_tag}` "
"only if this release needs them."
)
else:
next_step = (
f"Push any further changes to the `release/{release_tag}` branch that "
"are needed in this release (for example, adjusting the paired "
"`rust_versions` if the auto-pick isn't right for this iteration)."
)

body = (
"### What\n\n"
f"Stage a {kind} for stellar-cli {version}. `builds.json` is updated with "
"the rust base pins auto-picked from the current last two minor stable "
"releases on `rust-lang/rust`; each pin resolves the upstream base image "
"digest at append time (`<label>@sha256:<digest>`).\n\n"
f"{what}\n\n"
"### Why\n\n"
f"Triggered by @{actor} in {run_url}.\n\n"
"### What is next\n\n"
"See [RELEASE.md](./RELEASE.md) for the full release process.\n\n"
f"Push any further changes to the `release/{release_tag}` branch that "
"are needed in this release (for example, adjusting the paired "
"`rust_versions` if the auto-pick isn't right for this iteration).\n\n"
f"{next_step}\n\n"
"When this PR is reviewed and merged, create a GitHub Release by going to:\n\n"
f"https://github.com/{repo}/releases/new?tag={release_tag}"
f"&title={release_tag.removeprefix('v')}&target={default_branch}\n\n"
Expand Down Expand Up @@ -69,6 +98,11 @@ def build_parser() -> argparse.ArgumentParser:
default="body",
help="Which composed field to print (default: body).",
)
parser.add_argument(
"--skip-manifest-update",
action="store_true",
help="Describe an empty-commit re-trigger instead of a builds.json update.",
)
return parser


Expand All @@ -81,6 +115,7 @@ def main(argv: list[str] | None = None) -> int:
repo=args.repo,
run_url=args.run_url,
default_branch=args.default_branch,
skip_manifest_update=args.skip_manifest_update,
)
sys.stdout.write(title if args.field == "title" else body)
return 0
Expand Down
63 changes: 41 additions & 22 deletions scripts/release_prepare.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,38 +44,57 @@ def build_parser() -> argparse.ArgumentParser:
metavar="SLUG",
help="GitHub repo for release-tag lookups (default: stellar/stellar-cli-docker)",
)
parser.add_argument(
"--skip-manifest-update",
action="store_true",
help="Do not touch builds.json; just pick a release tag for an empty-commit refresh.",
)
return parser


def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
common.preflight_checks(["gh", "git", "buildx"])

cli = args.stellar_cli_version
before = builds.DEFAULT_PATH.read_bytes()

common.log(f"refreshing builds.json for stellar-cli {cli} ...")
refresh_argv = ["--stellar-cli-version", cli]
if args.rust_versions:
refresh_argv += ["--rust-versions", args.rust_versions]
if refresh.main(refresh_argv) != 0:
common.die("refresh failed; see above")

common.log("validating builds.json ...")
if validate_json.main([]) != 0:
common.die("validation failed; see above")

after = builds.DEFAULT_PATH.read_bytes()
if before == after:
common.die(
f"no changes to builds.json — nothing to release. The auto-picked rust "
f"versions and cli ref already match what's declared for stellar-cli {cli}."
)

if args.skip_manifest_update:
Comment thread
fnando marked this conversation as resolved.
# No builds.json mutation → no docker/refresh work, just a tag for the
# empty-commit refresh the push step will create.
common.preflight_checks(["gh", "git"])
if builds.find_cli(builds.load(), cli) is None:
common.die(
f"stellar-cli {cli} is not declared in builds.json — nothing to "
f"republish. Run without --skip-manifest-update to stage it first."
)
common.log("skipping builds.json update (--skip-manifest-update)")
else:
common.preflight_checks(["gh", "git", "buildx"])
before = builds.DEFAULT_PATH.read_bytes()

common.log(f"refreshing builds.json for stellar-cli {cli} ...")
refresh_argv = ["--stellar-cli-version", cli]
if args.rust_versions:
refresh_argv += ["--rust-versions", args.rust_versions]
if refresh.main(refresh_argv) != 0:
common.die("refresh failed; see above")

common.log("validating builds.json ...")
if validate_json.main([]) != 0:
common.die("validation failed; see above")

after = builds.DEFAULT_PATH.read_bytes()
if before == after:
common.die(
f"no changes to builds.json — nothing to release. The auto-picked rust "
f"versions and cli ref already match what's declared for stellar-cli {cli}."
)

release_tag = pick_release_tag(cli, args.repo)
common.log(f"release tag: {release_tag}")
common.log("")
common.log(f"release-prepare: builds.json staged for stellar-cli {cli}")
if args.skip_manifest_update:
common.log(f"release-prepare: builds.json left unchanged for stellar-cli {cli}")
else:
common.log(f"release-prepare: builds.json staged for stellar-cli {cli}")

print(release_tag)
return 0
Expand Down
18 changes: 14 additions & 4 deletions scripts/release_push_branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,15 @@ def remote_branch_exists(branch: str) -> bool:
return result.returncode == 0


def commit_and_push(release_tag: str, repo: str) -> int:
def commit_and_push(release_tag: str, repo: str, skip_manifest_update: bool = False) -> int:
branch = f"release/{release_tag}"
runner.run(["git", "add", "builds.json"])
runner.run(["git", "commit", "-m", f"Release {release_tag}."])
if skip_manifest_update:
# builds.json is untouched; carry the branch with an empty commit so the
# PR (and the publish flow on merge) still fires for the declared pairs.
runner.run(["git", "commit", "--allow-empty", "-m", f"Release {release_tag}."])
else:
runner.run(["git", "add", "builds.json"])
runner.run(["git", "commit", "-m", f"Release {release_tag}."])

force = False
if remote_branch_exists(branch):
Expand Down Expand Up @@ -66,13 +71,18 @@ def build_parser() -> argparse.ArgumentParser:
metavar="SLUG",
help="GitHub repo for open-PR lookups (default: stellar/stellar-cli-docker)",
)
parser.add_argument(
"--skip-manifest-update",
action="store_true",
help="Commit an empty change instead of staging builds.json.",
)
return parser


def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
common.preflight_checks(["gh", "git"])
return commit_and_push(args.release_tag, args.repo)
return commit_and_push(args.release_tag, args.repo, args.skip_manifest_update)


if __name__ == "__main__":
Expand Down
35 changes: 35 additions & 0 deletions tests/integration/test_release_prepare.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,41 @@ def fake_refresh(argv):
assert "27.0.0" in versions


def test_main_skips_manifest_update(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
staged_minimal: Path,
) -> None:
monkeypatch.setattr(release_prepare.common, "preflight_checks", lambda _: None)
monkeypatch.setattr(release_prepare.gh_cli, "list_release_tags", lambda _: ["v26.0.0"])
monkeypatch.setattr(
release_prepare.refresh, "main", lambda _: pytest.fail("refresh must not run")
)
monkeypatch.setattr(
release_prepare.validate_json, "main", lambda _: pytest.fail("validate must not run")
)

before = staged_minimal.read_bytes()
rc = release_prepare.main(["--stellar-cli-version", "26.0.0", "--skip-manifest-update"])
assert rc == 0
# A refresh iteration tag is still picked and emitted.
assert capsys.readouterr().out == "v26.0.0-1\n"
# builds.json is left byte-identical.
assert staged_minimal.read_bytes() == before


def test_main_skip_dies_for_undeclared_version(
monkeypatch: pytest.MonkeyPatch, staged_minimal: Path
) -> None:
monkeypatch.setattr(release_prepare.common, "preflight_checks", lambda _: None)
monkeypatch.setattr(
release_prepare.refresh, "main", lambda _: pytest.fail("refresh must not run")
)
# 99.0.0 is not in the minimal fixture — skip mode has nothing to republish.
with pytest.raises(SystemExit):
release_prepare.main(["--stellar-cli-version", "99.0.0", "--skip-manifest-update"])


def test_main_dies_when_nothing_changes(
monkeypatch: pytest.MonkeyPatch, staged_minimal: Path
) -> None:
Expand Down
19 changes: 18 additions & 1 deletion tests/unit/test_release_pr_body.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
import release_pr_body


def _compose(version="26.0.0", release_tag="v26.0.0"):
def _compose(version="26.0.0", release_tag="v26.0.0", skip_manifest_update=False):
return release_pr_body.compose(
version=version,
release_tag=release_tag,
actor="alice",
repo="stellar/stellar-cli-docker",
run_url="https://github.com/stellar/stellar-cli-docker/actions/runs/123",
default_branch="main",
skip_manifest_update=skip_manifest_update,
)


Expand All @@ -37,6 +38,22 @@ def test_body_describes_mutable_publish_behavior() -> None:
assert "mutable" in body


def test_skip_manifest_update_never_reads_as_new_release() -> None:
# A declared cli without a prior GitHub Release picks a suffix-less tag, but
# skip mode re-publishes existing pairs — it must not call itself a new release.
title, body = _compose(version="27.2.0", release_tag="v27.2.0", skip_manifest_update=True)
assert title == "Refresh stellar-cli 27.2.0"
assert "new release" not in body


def test_skip_manifest_update_body_omits_builds_update_claim() -> None:
_, body = _compose(release_tag="v26.0.0-1", skip_manifest_update=True)
# No claim that builds.json was changed; it's an empty-commit re-trigger.
assert "auto-pick" not in body
assert "builds.json` is updated" not in body
assert "unchanged" in body


def test_body_carries_release_url_with_correct_target() -> None:
_, body = _compose()
assert "https://github.com/stellar/stellar-cli-docker/releases/new" in body
Expand Down
Loading