From 5e10d4e13fa6cfb9decae7c641c3f507cb4727ab Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Fri, 22 May 2026 17:17:34 +0100 Subject: [PATCH] feat: add workflow dispatch for release creation --- .github/CONTRIBUTING.md | 33 +++-- .github/scripts/validate-release-version | 123 ++++++++++++++++++ .github/workflows/android-publish.yml | 14 ++ .github/workflows/release.yml | 157 +++++++++++++++++++++++ .github/workflows/swift-publish.yml | 14 ++ 5 files changed, 327 insertions(+), 14 deletions(-) create mode 100755 .github/scripts/validate-release-version create mode 100644 .github/workflows/release.yml diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index bf512bbe..9d267f83 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -83,16 +83,19 @@ When `dev swift api check` fails, it prints both the unified diff and a `swift-a Open a pull request with the following changes: 1. Bump the package version in `platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift`. -2. Bump the podspec version in `ShopifyCheckoutKit.podspec` (at the repo root). -3. Add an entry to the top of `platforms/swift/CHANGELOG.md`. +2. Bump the metadata version in `platforms/swift/Sources/ShopifyCheckoutKit/MetaData.swift`. +3. Bump the podspec version in `ShopifyCheckoutKit.podspec` (at the repo root). +4. Add an entry to the top of `platforms/swift/CHANGELOG.md`. -Once merged, draft a release on GitHub: +All Swift version declarations must match exactly. Supported release versions are `X.Y.Z` and prerelease versions are `X.Y.Z-{alpha|beta|rc}.N`. -1. Create a tag with the bare semver name (e.g. `3.8.1`) — Swift releases use bare semver so SwiftPM consumers can resolve them with `from:` constraints. -2. Use the same tag as the release name. -3. Document the changes since the previous release in the description. -4. Check "Set as the latest release". -5. Click "Publish release". This kicks off the [Swift publish workflow](../../actions/workflows/swift-publish.yml) which publishes the new version to CocoaPods. +Once merged, run the [Release package workflow](../../actions/workflows/release.yml): + +1. Select `iOS` as the platform. +2. Enter the expected version. The workflow reads the SDK version from the checked-in files and fails if the typed version does not match. +3. Leave `dry-run` enabled first to review the release plan. +4. Rerun with `dry-run` disabled. By default this creates a draft GitHub Release with the bare semver tag (e.g. `3.8.1`) for human review. +5. Publish the draft release when ready. Publishing the draft kicks off the [Swift publish workflow](../../actions/workflows/swift-publish.yml), which publishes the new version to CocoaPods. --- @@ -131,13 +134,15 @@ Open a pull request with the following changes: 1. Bump the `versionName` in `platforms/android/lib/build.gradle`. 2. Add an entry to the top of `platforms/android/CHANGELOG.md`. -Once merged, draft a release on GitHub: +Supported release versions are `X.Y.Z` and prerelease versions are `X.Y.Z-{alpha|beta|rc}.N`. + +Once merged, run the [Release package workflow](../../actions/workflows/release.yml): -1. Create a tag prefixed with `android/` (e.g. `android/3.0.1`) — Android releases use the `android/` prefix so the Maven publish workflow can distinguish them from Swift releases. -2. Use the same tag as the release name. -3. Document the changes since the previous release in the description. -4. Check "Set as the latest release". -5. Click "Publish release". This kicks off the [Android publish workflow](../../actions/workflows/android-publish.yml). **A manual approval by a maintainer is required before publication to Maven Central.** +1. Select `Android` as the platform. +2. Enter the expected version. The workflow reads the SDK version from `platforms/android/lib/build.gradle` and fails if the typed version does not match. +3. Leave `dry-run` enabled first to review the release plan. +4. Rerun with `dry-run` disabled. By default this creates a draft GitHub Release with the `android/`-prefixed tag (e.g. `android/3.0.1`) for human review. +5. Publish the draft release when ready. Publishing the draft kicks off the [Android publish workflow](../../actions/workflows/android-publish.yml). **A manual approval by a maintainer is required before publication to Maven Central.** --- diff --git a/.github/scripts/validate-release-version b/.github/scripts/validate-release-version new file mode 100755 index 00000000..068ad445 --- /dev/null +++ b/.github/scripts/validate-release-version @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'USAGE' +Usage: validate-release-version [expected-version] [expected-tag] + +Validates the selected SDK's checked-in version declarations, optional user +version input, optional git tag, and prints GitHub Actions outputs to stdout. + +Platforms: iOS, Android +USAGE +} + +if [ "$#" -lt 1 ] || [ "$#" -gt 3 ]; then + usage + exit 2 +fi + +PLATFORM_INPUT="$1" +EXPECTED_VERSION="${2:-}" +EXPECTED_TAG="${3:-}" + +SEMVER_REGEX='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(alpha|beta|rc)\.(0|[1-9][0-9]*))?$' + +extract_first_match() { + local file="$1" + local regex="$2" + local value + + if [ ! -f "$file" ]; then + echo "::error file=$file::Version source file does not exist." >&2 + exit 1 + fi + + value=$(sed -nE "$regex" "$file" | head -n 1) + if [ -z "$value" ]; then + echo "::error file=$file::Could not extract version." >&2 + exit 1 + fi + + printf '%s\n' "$value" +} + +check_same_version() { + local expected="$1" + local file="$2" + local actual="$3" + + if [ "$actual" != "$expected" ]; then + echo "::error file=$file::Version '$actual' does not match source version '$expected'." >&2 + exit 1 + fi +} + +case "$PLATFORM_INPUT" in + iOS|ios|swift|Swift) + PLATFORM="ios" + DISPLAY_PLATFORM="iOS" + TAG_PREFIX="" + PUBLISH_WORKFLOW="swift-publish.yml" + + PODSPEC_FILE="ShopifyCheckoutKit.podspec" + SWIFT_VERSION_FILE="platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift" + SWIFT_METADATA_FILE="platforms/swift/Sources/ShopifyCheckoutKit/MetaData.swift" + + VERSION=$(extract_first_match "$PODSPEC_FILE" 's/^[[:space:]]*s\.version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p') + SWIFT_VERSION=$(extract_first_match "$SWIFT_VERSION_FILE" 's/^[[:space:]]*public[[:space:]]+let[[:space:]]+version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p') + SWIFT_METADATA_VERSION=$(extract_first_match "$SWIFT_METADATA_FILE" 's/^[[:space:]]*package[[:space:]]+static[[:space:]]+let[[:space:]]+version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p') + + check_same_version "$VERSION" "$SWIFT_VERSION_FILE" "$SWIFT_VERSION" + check_same_version "$VERSION" "$SWIFT_METADATA_FILE" "$SWIFT_METADATA_VERSION" + ;; + + Android|android) + PLATFORM="android" + DISPLAY_PLATFORM="Android" + TAG_PREFIX="android/" + PUBLISH_WORKFLOW="android-publish.yml" + + ANDROID_VERSION_FILE="platforms/android/lib/build.gradle" + VERSION=$(extract_first_match "$ANDROID_VERSION_FILE" 's/^[[:space:]]*def[[:space:]]+versionName[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p') + ;; + + *) + echo "::error::Unsupported platform '$PLATFORM_INPUT'. Expected one of: iOS, Android." >&2 + exit 1 + ;; +esac + +if [[ ! "$VERSION" =~ $SEMVER_REGEX ]]; then + echo "::error::${DISPLAY_PLATFORM} SDK version '$VERSION' is invalid. Expected X.Y.Z or X.Y.Z-{alpha|beta|rc}.N." >&2 + exit 1 +fi + +if [ -n "$EXPECTED_VERSION" ] && [ "$EXPECTED_VERSION" != "$VERSION" ]; then + echo "::error::Requested version '$EXPECTED_VERSION' does not match ${DISPLAY_PLATFORM} SDK version '$VERSION'. Bump the SDK version in a PR first, or rerun with '$VERSION'." >&2 + exit 1 +fi + +TAG="${TAG_PREFIX}${VERSION}" + +if [ -n "$EXPECTED_TAG" ] && [ "$EXPECTED_TAG" != "$TAG" ]; then + echo "::error::Git tag '$EXPECTED_TAG' does not match ${DISPLAY_PLATFORM} SDK version '$VERSION'. Expected tag '$TAG'." >&2 + exit 1 +fi + +if [[ "$VERSION" == *-* ]]; then + PRERELEASE="true" +else + PRERELEASE="false" +fi + +{ + echo "platform=$PLATFORM" + echo "display_platform=$DISPLAY_PLATFORM" + echo "version=$VERSION" + echo "tag=$TAG" + echo "publish_workflow=$PUBLISH_WORKFLOW" + echo "prerelease=$PRERELEASE" +} + +echo "✓ ${DISPLAY_PLATFORM} version '$VERSION' validates and maps to tag '$TAG'." >&2 diff --git a/.github/workflows/android-publish.yml b/.github/workflows/android-publish.yml index e7dc78a6..7d1c0ad3 100644 --- a/.github/workflows/android-publish.yml +++ b/.github/workflows/android-publish.yml @@ -31,6 +31,20 @@ jobs: with: submodules: true + - name: Validate release tag matches Android version + working-directory: ${{ github.workspace }} + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + if [ "$GITHUB_EVENT_NAME" = "release" ]; then + TAG="$RELEASE_TAG" + else + TAG="$GITHUB_REF_NAME" + fi + + .github/scripts/validate-release-version Android "" "$TAG" + - name: Install JDK 1.17 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..171122d3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,157 @@ +name: Release package + +on: + workflow_dispatch: + inputs: + platform: + description: Platform to release + required: true + type: choice + options: + - iOS + - Android + version: + description: Expected SDK version. Must match the checked-in SDK version for the selected platform. + required: true + type: string + dry-run: + description: Validate and print the release plan, but do not create a release or dispatch publishing. + required: false + type: boolean + default: true + draft: + description: Create a draft GitHub Release for human review. Publish the draft manually to start publishing. + required: false + type: boolean + default: true + +permissions: + contents: write + actions: write + +concurrency: + group: release-${{ inputs.platform }} + cancel-in-progress: false + +jobs: + release: + name: Release ${{ inputs.platform }} ${{ inputs.version }} + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Validate requested release + id: release + env: + PLATFORM: ${{ inputs.platform }} + EXPECTED_VERSION: ${{ inputs.version }} + run: .github/scripts/validate-release-version "$PLATFORM" "$EXPECTED_VERSION" >> "$GITHUB_OUTPUT" + + - name: Require main branch for stable releases + if: steps.release.outputs.prerelease == 'false' && github.ref != 'refs/heads/main' + env: + REF: ${{ github.ref }} + run: | + echo "::error::Stable releases must be created from the main branch. Current ref: $REF" + exit 1 + + - name: Check tag and release do not already exist + env: + TAG: ${{ steps.release.outputs.tag }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "::error::Tag '${TAG}' already exists. Refusing to create a duplicate release." + exit 1 + fi + + if gh release view "$TAG" >/dev/null 2>&1; then + echo "::error::GitHub Release '${TAG}' already exists." + exit 1 + fi + + echo "::notice::Tag '${TAG}' and release '${TAG}' do not exist yet." + + - name: Print release plan + env: + DRY_RUN: ${{ inputs.dry-run }} + DISPLAY_PLATFORM: ${{ steps.release.outputs.display_platform }} + VERSION: ${{ steps.release.outputs.version }} + TAG: ${{ steps.release.outputs.tag }} + PRERELEASE: ${{ steps.release.outputs.prerelease }} + PUBLISH_WORKFLOW: ${{ steps.release.outputs.publish_workflow }} + DRAFT: ${{ inputs.draft }} + run: | + set -euo pipefail + echo "Release plan:" + echo " Platform: ${DISPLAY_PLATFORM}" + echo " Version: ${VERSION}" + echo " Tag: ${TAG}" + echo " Prerelease: ${PRERELEASE}" + echo " Publish workflow: ${PUBLISH_WORKFLOW}" + echo " Dry run: ${DRY_RUN}" + echo " Draft: ${DRAFT}" + if [ "$DRY_RUN" = "false" ] && [ "$DRAFT" = "true" ]; then + echo " Publish dispatch: skipped until the draft release is manually published" + elif [ "$DRY_RUN" = "false" ]; then + echo " Publish dispatch: ${PUBLISH_WORKFLOW} will be dispatched after release creation" + fi + + - name: Create GitHub Release + if: ${{ !inputs.dry-run }} + env: + TAG: ${{ steps.release.outputs.tag }} + PRERELEASE: ${{ steps.release.outputs.prerelease }} + DRAFT: ${{ inputs.draft }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + args=("$TAG" --target "$GITHUB_SHA" --title "$TAG" --generate-notes) + if [ "$DRAFT" = "true" ]; then + args+=(--draft) + fi + + if [ "$PRERELEASE" = "true" ]; then + args+=(--prerelease --latest=false) + elif [ "$DRAFT" = "false" ]; then + args+=(--latest) + fi + + gh release create "${args[@]}" + + - name: Dispatch publish workflow + if: ${{ !inputs.dry-run && !inputs.draft }} + env: + TAG: ${{ steps.release.outputs.tag }} + PUBLISH_WORKFLOW: ${{ steps.release.outputs.publish_workflow }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh workflow run "$PUBLISH_WORKFLOW" --ref "$TAG" + echo "::notice::Dispatched ${PUBLISH_WORKFLOW} at ${TAG}." + + - name: Summary + env: + DISPLAY_PLATFORM: ${{ steps.release.outputs.display_platform }} + VERSION: ${{ steps.release.outputs.version }} + TAG: ${{ steps.release.outputs.tag }} + PUBLISH_WORKFLOW: ${{ steps.release.outputs.publish_workflow }} + run: | + cat >> "$GITHUB_STEP_SUMMARY" <