diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 000000000..784439d43 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,111 @@ +name: Prepare Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Version number (e.g., 3.6.0)' + required: true + type: string + ticket: + description: 'Jira ticket number (e.g., 1234 for SDK-1234)' + required: true + type: string + docs_pr: + description: 'iterable-docs PR number (e.g., 123 for validate-release.yml)' + required: true + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + prepare-release: + runs-on: ubuntu-latest + env: + VERSION: ${{ github.event.inputs.version }} + DOCS_PR: ${{ github.event.inputs.docs_pr }} + steps: + - uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ vars.ITERABLE_SDK_RELEASE_APP_ID }} + private-key: ${{ secrets.ITERABLE_SDK_RELEASE_APP_PRIVATE_KEY }} + + - uses: actions/checkout@v4 + with: + ref: master + token: ${{ steps.app-token.outputs.token }} + + - name: Validate inputs + run: | + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Invalid version format. Use a stable semver release (e.g., 3.6.0). Prereleases are not supported by this workflow." + exit 1 + fi + if ! [[ "$DOCS_PR" =~ ^[0-9]+$ ]]; then + echo "::error::docs_pr must be the iterable-docs pull request number (digits only)." + exit 1 + fi + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Update Changelog + id: update_changelog + run: | + changelog_file="CHANGELOG.md" + + # RN has no [Unreleased] section -- insert a new ## version header at the top + temp_file=$(mktemp) + { + echo "## $VERSION" + echo "" + cat "$changelog_file" + } > "$temp_file" + mv "$temp_file" "$changelog_file" + + echo "See CHANGELOG.md for release notes." > "$RUNNER_TEMP/release-notes.md" + + - name: Bump package.json version + run: npm --no-git-tag-version --allow-same-version version "$VERSION" + + - name: Regenerate build info + run: node scripts/autoCreatePackageInfo.js + + - name: Create Pull Request + uses: peter-evans/create-pull-request@4e1beaa7521e8b457b572c090b25bd3db56bf1c5 # v5 + with: + token: ${{ steps.app-token.outputs.token }} + title: "SDK-${{ github.event.inputs.ticket }}: Prepare for Release ${{ github.event.inputs.version }}" + body: | + # Prepare for Release ${{ github.event.inputs.version }} + + Docs PR: https://github.com/Iterable/iterable-docs/pull/${{ github.event.inputs.docs_pr }} + + ## SDK Release Checklist + - [ ] CHANGELOG.md updated with release notes under the new version header + - [ ] Version bumped in package.json + - [ ] src/itblBuildInfo.ts regenerated + - [ ] README.md reviewed (if needed) + - [ ] All tests passing + - [ ] Documentation updated (if needed) + branch: "release/SDK-${{ github.event.inputs.ticket }}-${{ github.event.inputs.version }}" + commit-message: "[SDK-${{ github.event.inputs.ticket }}]: Prepare for release ${{ github.event.inputs.version }}" + labels: release + delete-branch: true + + - name: Create Draft GitHub Release + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + if gh release view "$VERSION" &>/dev/null; then + echo "Draft release $VERSION already exists, skipping." + else + gh release create "$VERSION" \ + --draft \ + --title "$VERSION" \ + --notes-file "$RUNNER_TEMP/release-notes.md" + fi diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 000000000..67a8c0321 --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,173 @@ +name: Publish Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to publish (e.g., 3.6.0)' + required: true + type: string + +env: + VERSION: ${{ github.event.inputs.version }} + +permissions: + contents: write + id-token: write + +jobs: + publish-release: + runs-on: ubuntu-latest + environment: npm + steps: + - uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ vars.ITERABLE_SDK_RELEASE_APP_ID }} + private-key: ${{ secrets.ITERABLE_SDK_RELEASE_APP_PRIVATE_KEY }} + + - name: Validate version format + run: | + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Invalid version format. Use a stable semver release (e.g., 3.6.0). Prereleases are not supported by this workflow." + exit 1 + fi + + - uses: actions/checkout@v4 + with: + ref: master + token: ${{ steps.app-token.outputs.token }} + + - name: Verify release is ready on master HEAD + run: | + changelog_top=$(grep -m1 -E '^## [0-9]+\.[0-9]+\.[0-9]+$' CHANGELOG.md | sed -E 's/^## //') + if [ "$changelog_top" != "$VERSION" ]; then + echo "::error::CHANGELOG.md top version is '$changelog_top', expected '$VERSION' at master HEAD. Merge the prepare-release PR before running this workflow." + exit 1 + fi + + pkg_ver=$(node -p "require('./package.json').version") + if [ "$pkg_ver" != "$VERSION" ]; then + echo "::error::package.json version is '$pkg_ver', expected '$VERSION'." + exit 1 + fi + + build_info_ver=$(grep -oP "version:\s*'\K[^']+" src/itblBuildInfo.ts) + if [ "$build_info_ver" != "$VERSION" ]; then + echo "::error::src/itblBuildInfo.ts version is '$build_info_ver', expected '$VERSION'." + exit 1 + fi + + - name: Extract changelog release notes + run: | + section=$(awk -v ver="$VERSION" ' + $0 == "## " ver { capturing=1; next } + capturing && /^## [0-9]+\.[0-9]+\.[0-9]+$/ { exit } + capturing { print } + ' CHANGELOG.md) + + if [ -z "$(printf '%s' "$section" | tr -d '[:space:]')" ]; then + echo "::error::No CHANGELOG.md content under ## $VERSION. Add release notes on the prepare-release PR before publishing." + exit 1 + fi + + printf '%s\n' "$section" > "$RUNNER_TEMP/release-notes.md" + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: 'yarn' + registry-url: 'https://registry.npmjs.org' + + - name: Install npm for OIDC trusted publishing + run: npm install -g npm@11.17.0 + + - run: yarn install --immutable + + - run: yarn test --maxWorkers=2 + + - run: yarn prepare + + - name: Publish draft GitHub release and tag master + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + target="$(git rev-parse HEAD)" + + if ! gh release view "$VERSION" &>/dev/null; then + echo "::error::No GitHub release named $VERSION. Run Prepare Release first." + exit 1 + fi + + release_tag_commit() { + local tag="$1" + local peeled direct + peeled=$(git ls-remote origin "refs/tags/${tag}^{}" 2>/dev/null | awk '{print $1}') + if [ -n "$peeled" ]; then + echo "$peeled" + return + fi + direct=$(git ls-remote origin "refs/tags/${tag}" 2>/dev/null | awk '{print $1}') + if [ -n "$direct" ]; then + echo "$direct" + fi + } + + assert_release_tag_at_head() { + local tag_sha attempt + for attempt in 1 2 3 4 5; do + tag_sha=$(release_tag_commit "$VERSION") + if [ -n "$tag_sha" ]; then + break + fi + sleep 2 + done + if [ -z "$tag_sha" ]; then + echo "::error::Release tag $VERSION not found on origin." + exit 1 + fi + if [ "$tag_sha" != "$target" ]; then + echo "::error::Release $VERSION tag points to $tag_sha but master HEAD is $target. Fix the release tag or run Publish from the commit the tag targets." + exit 1 + fi + } + + is_draft=$(gh release view "$VERSION" --json isDraft -q .isDraft) + if [ "$is_draft" = "true" ]; then + gh release edit "$VERSION" \ + --draft=false \ + --target "$target" \ + --latest \ + --notes-file "$RUNNER_TEMP/release-notes.md" + echo "Published GitHub release $VERSION at $target." + assert_release_tag_at_head + else + assert_release_tag_at_head + echo "GitHub release $VERSION is already published at $target, skipping release edit." + fi + + - name: Publish to npm + run: | + if npm view "@iterable/react-native-sdk@${VERSION}" version 2>/dev/null | grep -qxF "$VERSION"; then + echo "Version $VERSION is already on npm, skipping publish." + else + npm publish --provenance + fi + + - name: Slack notification + run: | + release_url="https://github.com/${{ github.repository }}/releases/tag/$VERSION" + payload=$(jq -n \ + --arg text ":package: *React Native SDK ${VERSION}* has been released. <${release_url}|View release notes>." \ + '{"text": $text}') + curl -sS -X POST -H 'Content-type: application/json' --data "$payload" "${{ secrets.SLACK_WEBHOOK }}" + + - name: Slack failure notification + if: failure() + run: | + run_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}" + payload=$(jq -n \ + --arg text ":alert: React Native SDK release ${VERSION} failed (attempt ${{ github.run_attempt }}). Run: ${run_url}" \ + '{"text": $text}') + curl -sS -X POST -H 'Content-type: application/json' --data "$payload" "${{ secrets.SLACK_WEBHOOK }}" diff --git a/package.json b/package.json index 686e9cc09..d408fa695 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "prebuild": "yarn add_build_info", "build": "yarn add_build_info && yarn bob build", "prepare": "yarn build", - "release": "release-it" + "release": "node ./scripts/deprecated-npm-release.js", + "release:legacy": "release-it" }, "keywords": [ "react-native", diff --git a/scripts/deprecated-npm-release.js b/scripts/deprecated-npm-release.js new file mode 100644 index 000000000..cb5850cae --- /dev/null +++ b/scripts/deprecated-npm-release.js @@ -0,0 +1,6 @@ +#!/usr/bin/env node + +console.error( + 'The yarn release script is deprecated. Publish via GitHub Actions: Prepare Release and Publish Release (.github/workflows/prepare-release.yml, publish-release.yml).' +); +process.exit(1); diff --git a/scripts/release_next_version.sh b/scripts/release_next_version.sh index 6c8f63700..46b2eea90 100755 --- a/scripts/release_next_version.sh +++ b/scripts/release_next_version.sh @@ -1,6 +1,9 @@ #!/bin/bash set -euxo pipefail + +echo "ERROR: scripts/release_next_version.sh is deprecated. Use GitHub Actions Prepare Release and Publish Release workflows." >&2 +exit 1 export SDK_DIR=$(cd `dirname "$0"`/.. && pwd) cd $SDK_DIR export VERSION=`sed -nE 's/"version": "([[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+)",/\1/p' package.json | xargs`