From 11d83a759ee8e43cfcd54b47c9157e77aa196daa Mon Sep 17 00:00:00 2001 From: derek-globus <113056046+derek-globus@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:54:29 -0400 Subject: [PATCH 1/4] Add stage management tests (#87) --- .../{test_roles.py => test_manage_roles.py} | 0 tests/commands/manage/test_manage_stages.py | 174 ++++++++++++++++++ ...test_targets.py => test_manage_targets.py} | 2 +- 3 files changed, 175 insertions(+), 1 deletion(-) rename tests/commands/manage/{test_roles.py => test_manage_roles.py} (100%) create mode 100644 tests/commands/manage/test_manage_stages.py rename tests/commands/manage/{test_targets.py => test_manage_targets.py} (99%) diff --git a/tests/commands/manage/test_roles.py b/tests/commands/manage/test_manage_roles.py similarity index 100% rename from tests/commands/manage/test_roles.py rename to tests/commands/manage/test_manage_roles.py diff --git a/tests/commands/manage/test_manage_stages.py b/tests/commands/manage/test_manage_stages.py new file mode 100644 index 0000000..d02d76a --- /dev/null +++ b/tests/commands/manage/test_manage_stages.py @@ -0,0 +1,174 @@ +# This file is a part of globus-registered-api. +# https://github.com/globus/globus-registered-api +# Copyright 2025-2026 Globus +# SPDX-License-Identifier: Apache-2.0 + +from types import SimpleNamespace +from uuid import UUID +from uuid import uuid4 + +import pytest + +from globus_registered_api.config import GRAConfig +from globus_registered_api.config import RoleConfig +from globus_registered_api.config import StageConfig +from globus_registered_api.config import TargetConfig +from globus_registered_api.repositories import SubscriptionRepository +from globus_registered_api.repositories.clients import GlobusClientRepository +from globus_registered_api.repositories.subscriptions import SubscriptionInfo + + +@pytest.fixture(autouse=True) +def patch_subscription_repository(monkeypatch): + repository = SubscriptionRepository.instance() + globus = GlobusClientRepository.instance() + + active_cache = repository._active_cache + known_cache = repository._known_cache + + for sub in (SUBS.UChicago, SUBS.Globus, SUBS.Harvard): + known_cache[sub.id] = sub + + active_cache[globus.cache_key] = [SUBS.UChicago, SUBS.Globus] + + +SUBS = SimpleNamespace( + UChicago=SubscriptionInfo(str(uuid4()), "UChicago"), + Globus=SubscriptionInfo(str(uuid4()), "Globus"), + Harvard=SubscriptionInfo(str(uuid4()), "Harvard"), +) + + +@pytest.fixture(autouse=True) +def autocommitted_config(config): + config.commit() + + +def test_stage_management_add_stage(prompt_patcher, mock_auth_client, gra): + user_id = UUID(mock_auth_client.userinfo()["sub"]) + + # Set up a sequence of selections to be made by the mocked selector. + prompt_patcher.add_selection("Manage Stages") + prompt_patcher.add_selection("") + + prompt_patcher.add_selection("Set Name") + prompt_patcher.add_input("click_prompt", "beta") + + prompt_patcher.add_selections("Set Subscription") + prompt_patcher.add_selection(f"{SUBS.Globus.name} ({SUBS.Globus.id})") + + prompt_patcher.add_selection("Set OpenAPI Location") + openapi_spec_path = "https://api.example.com/openapi.json" + prompt_patcher.add_input("prompt_toolkit_prompt", openapi_spec_path) + + prompt_patcher.add_selections("Set Base URL", "https://api.example.com") + + prompt_patcher.add_selections("", "") + + # Act + gra(["manage"], catch_exceptions=False) + + # Verify we've added the expected stage to the config and committed it. + user_role = RoleConfig(type="identity", id=user_id, access_level="owner") + assert GRAConfig.load().stages["beta"] == StageConfig( + subscription_id=SUBS.Globus.id, + specification=openapi_spec_path, + base_url="https://api.example.com", + roles=[user_role], + ) + + +def test_stage_management_remove_stage(prompt_patcher, config, gra): + config.stages["beta"] = StageConfig( + subscription_id=SUBS.Globus.id, + specification="https://api.example.com/openapi.json", + base_url="https://api.example.com", + roles=[], + ) + config.commit() + + # Set up a sequence of selections to be made by the mocked selector. + prompt_patcher.add_selections("Manage Stages", "Manage 'beta'") + + prompt_patcher.add_selection("Remove Stage") + prompt_patcher.add_input("confirmation", True) + + prompt_patcher.add_selection("") + + # Act + gra(["manage"], catch_exceptions=False) + + assert "beta" not in GRAConfig.load().stages + + +def test_stage_management_modify_stage(prompt_patcher, gra): + # Set up a sequence of selections to be made by the mocked selector. + prompt_patcher.add_selections("Manage Stages", "Manage 'production'") + + prompt_patcher.add_selection("Rename Stage") + prompt_patcher.add_input("click_prompt", "prod") + + prompt_patcher.add_selection("Modify Subscription") + prompt_patcher.add_selection(f"{SUBS.UChicago.name} ({SUBS.UChicago.id})") + + prompt_patcher.add_selection("Modify Base URL") + prompt_patcher.add_input("click_prompt", "https://totall-new-domain.com") + + prompt_patcher.add_selection("") + + # Act + gra(["manage"], catch_exceptions=False) + + updated_config = GRAConfig.load() + assert "production" not in updated_config.stages + updated_stage = updated_config.stages["prod"] + assert updated_stage.subscription_id == SUBS.UChicago.id + assert updated_stage.base_url == "https://totall-new-domain.com" + + +def test_stage_management_last_stage_removal_is_rejected(prompt_patcher, gra): + + # Set up a sequence of selections to be made by the mocked selector. + prompt_patcher.add_selections("Manage Stages", "Manage 'production'") + prompt_patcher.add_selection("Remove Stage") + + prompt_patcher.add_selection("") + + # Act + result = gra(["manage"], catch_exceptions=False) + + # Verify we didn't delete the stage & informed the user why. + assert "production" in GRAConfig.load().stages + assert "Cannot remove the only remaining stage" in result.output + assert "add a new stage" in result.output + + +def test_stage_management_rename_stage_updates_targets( + prompt_patcher, + config, + gra, +): + # Add a target explicitly pointing at the "production" stage. + config.targets = { + "get-example": TargetConfig( + path="/example", + method="GET", + description="Desc", + stages=["production"], + ), + } + config.commit() + + # Set up a sequence of selections to be made by the mocked selector. + prompt_patcher.add_selections("Manage Stages", "Manage 'production'") + + prompt_patcher.add_selection("Rename Stage") + prompt_patcher.add_input("click_prompt", "prod") + + prompt_patcher.add_selection("") + + # Act + gra(["manage"], catch_exceptions=False) + + # Verify the target now points at 'prod' instead of 'production' + assert GRAConfig.load().targets["get-example"].stages == ["prod"] diff --git a/tests/commands/manage/test_targets.py b/tests/commands/manage/test_manage_targets.py similarity index 99% rename from tests/commands/manage/test_targets.py rename to tests/commands/manage/test_manage_targets.py index 5fe7696..62788a1 100644 --- a/tests/commands/manage/test_targets.py +++ b/tests/commands/manage/test_manage_targets.py @@ -22,7 +22,7 @@ def rich_disabled_colors(monkeypatch): @pytest.fixture(autouse=True) -def committed_config(config): +def autocommitted_config(config): config.commit() From 6adfc64e44b1954e872e7c675fec447e4d174aed Mon Sep 17 00:00:00 2001 From: derek-globus <113056046+derek-globus@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:21:12 -0400 Subject: [PATCH 2/4] Pass internal fields through the manifest (#90) --- ...13159_derek_data_templates_in_manifest.rst | 5 ++ src/globus_registered_api/commands/build.py | 13 +++-- .../commands/publish/domain.py | 6 +- .../commands/publish/publisher.py | 34 ++++-------- src/globus_registered_api/manifest.py | 16 +++++- tests/commands/test_build.py | 55 +++++++++++++++++++ tests/openapi/test_reducer.py | 4 +- tests/test_config.py | 2 +- 8 files changed, 100 insertions(+), 35 deletions(-) create mode 100644 changelog.d/20260602_113159_derek_data_templates_in_manifest.rst diff --git a/changelog.d/20260602_113159_derek_data_templates_in_manifest.rst b/changelog.d/20260602_113159_derek_data_templates_in_manifest.rst new file mode 100644 index 0000000..610dad8 --- /dev/null +++ b/changelog.d/20260602_113159_derek_data_templates_in_manifest.rst @@ -0,0 +1,5 @@ + +Development +----------- + +* Pass internal fields through the generated manifest. diff --git a/src/globus_registered_api/commands/build.py b/src/globus_registered_api/commands/build.py index 4dffa48..e56e5fe 100644 --- a/src/globus_registered_api/commands/build.py +++ b/src/globus_registered_api/commands/build.py @@ -9,8 +9,8 @@ import click from globus_registered_api.config import GRAConfig -from globus_registered_api.manifest import ComputedRegisteredAPI from globus_registered_api.manifest import GRAManifest +from globus_registered_api.manifest import RegisteredAPIManifest from globus_registered_api.openapi import process_target from globus_registered_api.openapi.enricher import OpenAPIEnricher from globus_registered_api.openapi.loader import load_openapi_spec @@ -49,7 +49,7 @@ def build_command() -> None: def _compute_registered_apis_for_stage( config: GRAConfig, stage: str -) -> dict[str, ComputedRegisteredAPI]: +) -> dict[str, RegisteredAPIManifest]: stage_config = config.stages[stage] GlobusClientRepository.instance().environment = stage_config.globus_environment @@ -60,11 +60,14 @@ def _compute_registered_apis_for_stage( enriched_spec = OpenAPIEnricher(config, stage).enrich(openapi_spec) # Process each target - registered_apis: dict[str, ComputedRegisteredAPI] = {} + registered_apis: dict[str, RegisteredAPIManifest] = {} for alias, target_config in config.targets.items(): if target_config.stages == "*" or stage in target_config.stages: result = process_target(enriched_spec, target_config.specifier) - registered_apis[alias] = ComputedRegisteredAPI( - target=result.target, description=target_config.description + registered_apis[alias] = RegisteredAPIManifest( + target=result.target, + description=target_config.description, + data_templates=target_config.data_templates, + state_input_schema=target_config.state_input_schema, ) return registered_apis diff --git a/src/globus_registered_api/commands/publish/domain.py b/src/globus_registered_api/commands/publish/domain.py index 7d3e046..7d99a90 100644 --- a/src/globus_registered_api/commands/publish/domain.py +++ b/src/globus_registered_api/commands/publish/domain.py @@ -7,8 +7,8 @@ from globus_registered_api.config import GRAConfig from globus_registered_api.config import StageConfig -from globus_registered_api.manifest import ComputedRegisteredAPI from globus_registered_api.manifest import GRAManifest +from globus_registered_api.manifest import RegisteredAPIManifest @dataclass @@ -25,10 +25,10 @@ def stage_config(self) -> StageConfig: return self.config.stages[self.stage] @property - def registered_apis(self) -> dict[str, ComputedRegisteredAPI]: + def registered_apis(self) -> dict[str, RegisteredAPIManifest]: """ Access point for registered apis in the current stage. - :return: A mapping of API aliases to ComputedRegisteredAPIs + :return: A mapping of API aliases to RegisteredAPIManifests. """ return self.manifest.registered_apis[self.stage] diff --git a/src/globus_registered_api/commands/publish/publisher.py b/src/globus_registered_api/commands/publish/publisher.py index 35bf420..1dc7b2b 100644 --- a/src/globus_registered_api/commands/publish/publisher.py +++ b/src/globus_registered_api/commands/publish/publisher.py @@ -9,7 +9,6 @@ from globus_registered_api.config import RegisteredAPIConfig from globus_registered_api.config import RoleConfig -from globus_registered_api.config import TargetConfig from globus_registered_api.errors import GRAArgumentError from globus_registered_api.repositories.clients import GlobusClientRepository @@ -77,18 +76,17 @@ def publish_target(context: PublishContext, alias: str) -> None: :param context: PublishContext with client and data :param alias: The alias of the target to publish """ - target_config = context.config.targets[alias] if alias not in context.stage_config.registered_apis: # Create a Registered API, storing the generated ID back into config. - api_id = _create_target(context, alias, target_config) + api_id = _create_target(context, alias) new_config = RegisteredAPIConfig(registered_api_id=api_id) context.stage_config.registered_apis[alias] = new_config else: # Modify the existing Registered API api_id = context.stage_config.registered_apis[alias].registered_api_id - _update_target(context, api_id, alias, target_config) + _update_target(context, api_id, alias) # Commit immediately after each successful publish context.config.commit() @@ -97,32 +95,28 @@ def publish_target(context: PublishContext, alias: str) -> None: def _create_target( context: PublishContext, alias: str, - target: TargetConfig, ) -> UUID: """ Create a new registered API in Flows service. :param context: PublishContext with client and data :param alias: The alias of the target - :param target: The target configuration """ click.echo(f"Creating '{alias}'...") - # TODO - source other metadata from the manifest, not the config. - target_def = context.registered_apis[alias].target.to_dict() - description = context.registered_apis[alias].description + registered_api = context.registered_apis[alias] flows_client = GlobusClientRepository.instance().flows response = flows_client.create_registered_api( name=alias, - description=description, - target=target_def, + description=registered_api.description, + target=registered_api.target.to_dict(), subscription_id=context.stage_config.subscription_id, owners=context.role_urns["owners"] or None, administrators=context.role_urns["administrators"] or None, viewers=context.role_urns["viewers"] or None, - data_templates=target.data_templates, - state_input_schema=target.state_input_schema, + data_templates=registered_api.data_templates, + state_input_schema=registered_api.state_input_schema, ) click.echo(f" Created with ID: {response['id']}") @@ -133,7 +127,6 @@ def _update_target( context: PublishContext, registered_api_id: UUID, alias: str, - target: TargetConfig, ) -> None: """ Update an existing registered API in Flows service. @@ -141,26 +134,23 @@ def _update_target( :param context: PublishContext with client and data :param registered_api_id: The registered API ID :param alias: The alias of the target - :param target: The target configuration """ click.echo(f"Updating '{alias}' (ID: {str(registered_api_id)})...") - # TODO - source other metadata from the manifest, not the config. - target_def = context.registered_apis[alias].target.to_dict() - description = context.registered_apis[alias].description + registered_api = context.registered_apis[alias] flows_client = GlobusClientRepository.instance().flows flows_client.update_registered_api( str(registered_api_id), name=alias, - description=description, - target=target_def, + description=registered_api.description, + target=registered_api.target.to_dict(), subscription_id=context.stage_config.subscription_id, owners=context.role_urns["owners"] or None, administrators=context.role_urns["administrators"] or None, viewers=context.role_urns["viewers"] or None, - data_templates=target.data_templates, - state_input_schema=target.state_input_schema, + data_templates=registered_api.data_templates, + state_input_schema=registered_api.state_input_schema, ) click.echo(" Updated successfully") diff --git a/src/globus_registered_api/manifest.py b/src/globus_registered_api/manifest.py index 219dc35..88c7448 100644 --- a/src/globus_registered_api/manifest.py +++ b/src/globus_registered_api/manifest.py @@ -45,7 +45,7 @@ class GRAManifest(BaseModel): document_version: str = Field(default=_CURRENT_VERSION) build_timestamp: datetime - registered_apis: dict[str, dict[str, ComputedRegisteredAPI]] + registered_apis: dict[str, dict[str, RegisteredAPIManifest]] @field_serializer("build_timestamp", mode="plain") def serialize_timestamp(self, value: datetime) -> str: @@ -98,12 +98,24 @@ def path(cls) -> Path: return _MANIFEST_PATH -class ComputedRegisteredAPI(BaseModel): +class RegisteredAPIManifest(BaseModel): """Represents a single registered API target with its enriched specification.""" target: OpenAPITarget description: str + data_templates: dict[str, t.Any] | None = Field( + default=None, + repr=False, # Exclude from on-screen display rendering. + exclude_if=lambda v: v is None, # Exclude from serializing if None. + ) + + state_input_schema: dict[str, t.Any] | None = Field( + default=None, + repr=False, # Exclude from on-screen display rendering. + exclude_if=lambda v: v is None, # Exclude from serializing if None. + ) + @field_validator("target", mode="before") @classmethod def ensure_target(cls, value: dict[str, t.Any] | OpenAPITarget) -> OpenAPITarget: diff --git a/tests/commands/test_build.py b/tests/commands/test_build.py index 9aad2c2..2694be0 100644 --- a/tests/commands/test_build.py +++ b/tests/commands/test_build.py @@ -336,3 +336,58 @@ def test_manifest_directory_creation(gra, config_with_targets, tmp_path, monkeyp assert result.exit_code == 0 assert new_manifest_path.exists() assert new_manifest_path.parent.exists() + + +def test_build_omits_unspecified_internal_fields(gra, config, manifest_path): + # Arrange + config.targets["create-example"] = TargetConfig( + path="/example", + method="POST", + description="Create example resource", + # No internal fields (data_templates or state_input_schema) + ) + config.commit() + + # Act + gra(["build"], catch_exceptions=False) + + # Assert that the internal fields were not written to disk. + raw_manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + raw_registered_apis = raw_manifest["registered_apis"]["production"] + assert "data_templates" not in raw_registered_apis["create-example"] + assert "state_input_schema" not in raw_registered_apis["create-example"] + + # Verify that loading the manifest without these fields does not error. + GRAManifest.load() + + +def test_build_includes_specified_internal_fields(gra, config): + # Arrange + data_templates = { + "request": {"query": {"$T_ref": "$"}}, + "response": {"default": {"$T_ref": "$.body"}}, + } + state_input_schema = { + "properties": { + "example_id": {"type": "string"}, + }, + "additionalProperties": False, + } + + config.targets["create-example"] = TargetConfig( + path="/example", + method="POST", + description="Create example resource", + data_templates=data_templates, + state_input_schema=state_input_schema, + ) + config.commit() + + # Act + gra(["build"], catch_exceptions=False) + + # Verify that the internal fields were properly copied over to the manifest. + manifest = GRAManifest.load() + registered_api = manifest.registered_apis["production"]["create-example"] + assert registered_api.data_templates == data_templates + assert registered_api.state_input_schema == state_input_schema diff --git a/tests/openapi/test_reducer.py b/tests/openapi/test_reducer.py index da38742..f6419a3 100644 --- a/tests/openapi/test_reducer.py +++ b/tests/openapi/test_reducer.py @@ -202,7 +202,7 @@ def test_reduce_to_target_handles_server_url_with_trailing_slash(temp_spec_file) "/items": { "get": { "summary": "List", - "responses": {"200": {"description": "OK"}} + "response": {"200": {"description": "OK"}} } } } @@ -234,7 +234,7 @@ def test_reduce_to_target_handles_circular_references(temp_spec_file): "/nodes": { "get": { "summary": "Get nodes", - "responses": { + "response": { "200": { "description": "OK", "content": { diff --git a/tests/test_config.py b/tests/test_config.py index 3052741..b65e73c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -97,7 +97,7 @@ def test_target_config_data_templates_defaults_to_none(): def test_target_config_serialization_includes_data_templates_if_set(): - data_templates = {"requests": {}, "responses": {"2XX": {}}} + data_templates = {"requests": {}, "response": {"2XX": {}}} target = TargetConfig( path="/test", method="GET", From 052ca972cebd19197ea0d918a342436fa02ebbcc Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Wed, 3 Jun 2026 15:23:06 -0500 Subject: [PATCH 3/4] Add a documented and semi-automated release process --- RELEASING.md | 60 +++++++++++++++++++ assets/releasing/r1-prep-release.sh | 53 ++++++++++++++++ assets/releasing/r2-amend-changelog.sh | 7 +++ assets/releasing/r3-create-pr.sh | 33 ++++++++++ assets/releasing/r4-publish.sh | 29 +++++++++ assets/releasing/r5-merge-back.sh | 23 +++++++ ...4_105715_kurtmckee_add_release_process.rst | 4 ++ justfile | 20 +++++++ 8 files changed, 229 insertions(+) create mode 100644 RELEASING.md create mode 100644 assets/releasing/r1-prep-release.sh create mode 100644 assets/releasing/r2-amend-changelog.sh create mode 100644 assets/releasing/r3-create-pr.sh create mode 100644 assets/releasing/r4-publish.sh create mode 100644 assets/releasing/r5-merge-back.sh create mode 100644 changelog.d/20260604_105715_kurtmckee_add_release_process.rst diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..e7eba07 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,60 @@ +# Releasing new versions + +Follow this process to release new versions of the GRA CLI. + +> [!NOTE] +> +> This process depends on the following tools: +> +> * bash +> * git +> * GitHub CLI +> * just +> * pandoc +> * Poetry +> * twine + + +## STEP 1: Prep the release branch + +Choose a new version number and run this command, +substituting the version number for the word "VERSION". + +```shell +just r1-prep-release "VERSION" +``` + + +## STEP 2: Review the CHANGELOG + +Review the CHANGELOG for readability, flow, and consistency. +If changes are made, use this command to commit the changes: + +```shell +just r2-amend-changelog +``` + + +## STEP 3: Create the release PR + +```shell +just r3-create-pr +``` + +Wait for the release PR to merge. + + +## STEP 4: Publish + +After the release PR merges, run this command: + +```shell +just r4-publish +``` + + +## STEP 5: Merge back to main + +```shell +just r5-merge-back +``` diff --git a/assets/releasing/r1-prep-release.sh b/assets/releasing/r1-prep-release.sh new file mode 100644 index 0000000..c28fde7 --- /dev/null +++ b/assets/releasing/r1-prep-release.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -e + +require_exit='0' + +required_commands=( + gh + pandoc + poetry + scriv +) + +for required_command in "${required_commands[@]}"; do + if ! command -v "${required_command}" 1>/dev/null 2>/dev/null; then + echo "${required_command} must be installed." + require_exit='1' + fi +done + +# Require that `main` is already checked out. +current_branch="$(git rev-parse --abbrev-ref HEAD)" +if [ "${current_branch}" != 'main' ]; then + echo "You must be on the 'main' branch." + require_exit='1' +fi + +# Require a single argument, representing the new version. +if [ "$1" == '' ]; then + echo + echo "USAGE: $0 {VERSION}" + require_exit='1' +fi + +if [ "${require_exit}" -eq '1' ]; then + exit 1 +fi + +# Set the new version. +export VERSION="$1" +export BRANCH="release/${VERSION}" + +# Pull the latest changes. +git pull + +# Bump the metadata. +git checkout -b "release/${VERSION}" +poetry version "${VERSION}" +scriv collect + +# Commit the changes +git add changelog.d/ CHANGELOG.rst pyproject.toml +git commit -m 'Update project metadata' diff --git a/assets/releasing/r2-amend-changelog.sh b/assets/releasing/r2-amend-changelog.sh new file mode 100644 index 0000000..99d55ee --- /dev/null +++ b/assets/releasing/r2-amend-changelog.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +# Amend the CHANGELOG. +git add CHANGELOG.rst +git commit --amend -m 'Update project metadata' diff --git a/assets/releasing/r3-create-pr.sh b/assets/releasing/r3-create-pr.sh new file mode 100644 index 0000000..3340a00 --- /dev/null +++ b/assets/releasing/r3-create-pr.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +set -e + +export VERSION="$(poetry version --short)" +export BRANCH="release/${VERSION}" + +# Require that `release/$VERSION` branch is already checked out. +current_branch="$(git rev-parse --abbrev-ref HEAD)" +if [ "${current_branch}" != "${BRANCH}" ]; then + echo "You must be on the '${BRANCH}' branch." + exit 1 +fi + +# Generate the CHANGELOG fragment. +export CHANGELOG_FRAGMENT="$(mktemp --suffix '.md')" + +echo ' +> [!NOTE] +> +> Merge with the "Create a merge commit" strategy! +' > "${CHANGELOG_FRAGMENT}" + +scriv print --version "${VERSION}" \ + | pandoc --from rst --to gfm --wrap preserve --shift-heading-level-by 1 \ + >> "${CHANGELOG_FRAGMENT}" + +# Create the PR. +git push origin --set-upstream "${BRANCH}" +gh pr create \ + --title "Release ${VERSION}" \ + --body-file "${CHANGELOG_FRAGMENT}" \ + --base 'releases' diff --git a/assets/releasing/r4-publish.sh b/assets/releasing/r4-publish.sh new file mode 100644 index 0000000..e79ddb8 --- /dev/null +++ b/assets/releasing/r4-publish.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +set -e + +# Check out the 'releases' branch. +git checkout releases +git pull + +export VERSION="$(poetry version --short)" + +# Generate the CHANGELOG fragment. +export CHANGELOG_FRAGMENT="$(mktemp --suffix '.md')" +scriv print --version "${VERSION}" \ + | pandoc --from rst --to gfm --wrap preserve --shift-heading-level-by 1 \ + > "${CHANGELOG_FRAGMENT}" + +# Publish the git artifacts. +git tag "v${VERSION}" --annotate --file="${CHANGELOG_FRAGMENT}" +git push --tags # This line triggers a CI job that publishes to PyPI. +gh release create "v${VERSION}" \ + --target 'releases' \ + --title "${VERSION}" \ + --notes-from-tag + +# Publish the PyPI artifacts. +rm -rf dist/ +poetry build --no-plugins +twine check --strict dist/* +twine upload dist/* diff --git a/assets/releasing/r5-merge-back.sh b/assets/releasing/r5-merge-back.sh new file mode 100644 index 0000000..b7d8115 --- /dev/null +++ b/assets/releasing/r5-merge-back.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -e + +# Push a new branch to the repo. +# If merge conflicts exist, the 'merge-back' branch is where the conflicts can be resolved. +git fetch origin +git push origin refs/remotes/origin/releases:refs/heads/merge-back + +# Create the merge-back PR. +export PR_BODY="$(mktemp --suffix '.md')" +echo ' +> [!NOTE] +> +> Merge with the "Create a merge commit" strategy! +' > "${PR_BODY}" +gh pr create \ + --title 'Merge back to main' \ + --body-file "${PR_BODY}" \ + --base 'main' \ + --head 'merge-back' \ + --assignee '@me' \ + --label 'no-news-is-good-news' diff --git a/changelog.d/20260604_105715_kurtmckee_add_release_process.rst b/changelog.d/20260604_105715_kurtmckee_add_release_process.rst new file mode 100644 index 0000000..7722955 --- /dev/null +++ b/changelog.d/20260604_105715_kurtmckee_add_release_process.rst @@ -0,0 +1,4 @@ +Development +----------- + +* Add a documented and semi-automated release process. diff --git a/justfile b/justfile index 914da91..518c6f8 100644 --- a/justfile +++ b/justfile @@ -38,3 +38,23 @@ clean: rm -rf build rm -f .coverage.* find . \( -type d -name __pycache__ -or -name \*.py[oc] \) -delete + +# RELEASING 1: Create a new branch and update the project metadata. +r1-prep-release version: + bash assets/releasing/r1-prep-release.sh "{{version}}" + +# RELEASING 2: (OPTIONAL) Amend the CHANGELOG after changes are made. +r2-amend-changelog: + bash assets/releasing/r2-amend-changelog.sh + +# RELEASING 3: Create the release PR. +r3-create-pr: + bash assets/releasing/r3-create-pr.sh + +# RELEASING 4: Publish a git tag and GitHub release. +r4-publish: + bash assets/releasing/r4-publish.sh + +# RELEASING 5: Create the merge-back-to-main PR. +r5-merge-back: + bash assets/releasing/r5-merge-back.sh From 35b8e7a5f4bb0c2a78f972dd2066daec477d09a5 Mon Sep 17 00:00:00 2001 From: derek-globus Date: Mon, 8 Jun 2026 09:41:57 -0500 Subject: [PATCH 4/4] Update project metadata --- CHANGELOG.rst | 10 ++++++++++ ...0260602_113159_derek_data_templates_in_manifest.rst | 5 ----- .../20260604_105715_kurtmckee_add_release_process.rst | 4 ---- pyproject.toml | 2 +- 4 files changed, 11 insertions(+), 10 deletions(-) delete mode 100644 changelog.d/20260602_113159_derek_data_templates_in_manifest.rst delete mode 100644 changelog.d/20260604_105715_kurtmckee_add_release_process.rst diff --git a/CHANGELOG.rst b/CHANGELOG.rst index aaa38bc..1071cf9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -26,6 +26,16 @@ Please see the fragment files in the `changelog.d directory`_. .. scriv-insert-here +.. _changelog-1.0.1: + +1.0.1 - 2026-06-08 +================== + +Development +----------- + +* Internal robustness improvements. + .. _changelog-1.0.0: 1.0.0 - 2026-06-01 diff --git a/changelog.d/20260602_113159_derek_data_templates_in_manifest.rst b/changelog.d/20260602_113159_derek_data_templates_in_manifest.rst deleted file mode 100644 index 610dad8..0000000 --- a/changelog.d/20260602_113159_derek_data_templates_in_manifest.rst +++ /dev/null @@ -1,5 +0,0 @@ - -Development ------------ - -* Pass internal fields through the generated manifest. diff --git a/changelog.d/20260604_105715_kurtmckee_add_release_process.rst b/changelog.d/20260604_105715_kurtmckee_add_release_process.rst deleted file mode 100644 index 7722955..0000000 --- a/changelog.d/20260604_105715_kurtmckee_add_release_process.rst +++ /dev/null @@ -1,4 +0,0 @@ -Development ------------ - -* Add a documented and semi-automated release process. diff --git a/pyproject.toml b/pyproject.toml index 2669839..9bf4949 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "globus-registered-api" -version = "1.0.0" +version = "1.0.1" description = "Manage Registered APIs in the Globus Flows service" authors = [ { name = "Kurt McKee", email = "support@globus.org" },