Release #203
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| release-type: | |
| description: "Release type" | |
| required: true | |
| type: choice | |
| options: | |
| - "dev" | |
| - "rc" | |
| - "final" | |
| ref: | |
| description: "Branch to release from (defaults to the branch the workflow is run from)" | |
| required: false | |
| type: string | |
| rc-number: | |
| description: "RC number (required for RC releases)" | |
| required: false | |
| type: number | |
| ios: | |
| description: "Release iOS SDK" | |
| required: false | |
| type: boolean | |
| default: false | |
| android: | |
| description: "Release Android SDK" | |
| required: false | |
| type: boolean | |
| default: false | |
| node-sdk: | |
| description: "Release Node SDK" | |
| required: false | |
| type: boolean | |
| default: false | |
| browser-sdk: | |
| description: "Release Browser SDK" | |
| required: false | |
| type: boolean | |
| default: false | |
| no-merge: | |
| description: "Skip merging the release PR to main (only applies to final releases)" | |
| required: false | |
| type: boolean | |
| default: false | |
| dry-run: | |
| description: "Compute + print the full release plan but skip ALL publishes/tags" | |
| required: false | |
| type: boolean | |
| default: false | |
| schedule: | |
| # Nightly: 06:00 UTC daily (~11pm PT / 02am ET). Schedule-only, never manual. | |
| - cron: "0 6 * * *" | |
| jobs: | |
| validate: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| release-type: ${{ steps.resolve.outputs.release-type }} | |
| ref: ${{ steps.resolve.outputs.ref }} | |
| ios: ${{ steps.resolve.outputs.ios }} | |
| android: ${{ steps.resolve.outputs.android }} | |
| node-sdk: ${{ steps.resolve.outputs.node-sdk }} | |
| browser-sdk: ${{ steps.resolve.outputs.browser-sdk }} | |
| dry-run: ${{ steps.resolve.outputs.dry-run }} | |
| skip: ${{ steps.skip-check.outputs.skip }} | |
| steps: | |
| - name: Resolve release parameters | |
| id: resolve | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| INPUT_RELEASE_TYPE: ${{ inputs.release-type }} | |
| INPUT_REF: ${{ inputs.ref }} | |
| GITHUB_REF: ${{ github.ref }} | |
| INPUT_IOS: ${{ inputs.ios }} | |
| INPUT_ANDROID: ${{ inputs.android }} | |
| INPUT_NODE_SDK: ${{ inputs.node-sdk }} | |
| INPUT_BROWSER_SDK: ${{ inputs.browser-sdk }} | |
| INPUT_DRY_RUN: ${{ inputs.dry-run }} | |
| run: | | |
| if [ "$EVENT_NAME" = "schedule" ]; then | |
| echo "release-type=nightly" >> "$GITHUB_OUTPUT" | |
| echo "ref=main" >> "$GITHUB_OUTPUT" | |
| echo "ios=true" >> "$GITHUB_OUTPUT" | |
| echo "android=true" >> "$GITHUB_OUTPUT" | |
| echo "node-sdk=true" >> "$GITHUB_OUTPUT" | |
| echo "browser-sdk=true" >> "$GITHUB_OUTPUT" | |
| echo "dry-run=false" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "release-type=$INPUT_RELEASE_TYPE" >> "$GITHUB_OUTPUT" | |
| echo "ref=${INPUT_REF:-$GITHUB_REF}" >> "$GITHUB_OUTPUT" | |
| echo "ios=$INPUT_IOS" >> "$GITHUB_OUTPUT" | |
| echo "android=$INPUT_ANDROID" >> "$GITHUB_OUTPUT" | |
| echo "node-sdk=$INPUT_NODE_SDK" >> "$GITHUB_OUTPUT" | |
| echo "browser-sdk=$INPUT_BROWSER_SDK" >> "$GITHUB_OUTPUT" | |
| echo "dry-run=${INPUT_DRY_RUN:-false}" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Validate dev release | |
| if: steps.resolve.outputs.release-type == 'dev' | |
| env: | |
| RC_NUMBER: ${{ inputs.rc-number }} | |
| run: | | |
| if [ -n "$RC_NUMBER" ]; then | |
| echo "::error::Dev releases should not have an RC number set" | |
| exit 1 | |
| fi | |
| - name: Validate release branch | |
| if: steps.resolve.outputs.release-type == 'rc' || steps.resolve.outputs.release-type == 'final' | |
| env: | |
| REF: ${{ steps.resolve.outputs.ref }} | |
| run: | | |
| BRANCH="${REF#refs/heads/}" | |
| if [[ ! "$BRANCH" =~ ^release/ ]]; then | |
| echo "::error::RC and final releases must be run from a release/* branch, got: $BRANCH" | |
| exit 1 | |
| fi | |
| - name: Validate RC number | |
| if: steps.resolve.outputs.release-type == 'rc' | |
| env: | |
| RC_NUMBER: ${{ inputs.rc-number }} | |
| run: | | |
| if [ -z "$RC_NUMBER" ] || [ "$RC_NUMBER" -lt 1 ]; then | |
| echo "::error::RC releases require rc-number to be set to a value greater than 0" | |
| exit 1 | |
| fi | |
| - name: Skip-if-no-new-commits (nightly only) | |
| id: skip-check | |
| if: steps.resolve.outputs.release-type == 'nightly' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| REF: ${{ steps.resolve.outputs.ref }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| set -euo pipefail | |
| HEAD_SHA=$(gh api "repos/${REPO}/commits/${REF}" --jq '.sha[0:7]') | |
| LAST_TAG=$(gh api "repos/${REPO}/git/matching-refs/tags/ios-" \ | |
| --jq '[.[] | .ref | sub("refs/tags/"; "")] | map(select(test("-nightly\\."))) | sort | last // ""') | |
| if [ -n "$LAST_TAG" ]; then | |
| LAST_SHA=$(echo "$LAST_TAG" | grep -oE '[0-9a-f]{7}$' || true) | |
| if [ "$LAST_SHA" = "$HEAD_SHA" ]; then | |
| echo "::notice::No new commits since last nightly ($LAST_TAG); skipping." | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| fi | |
| echo "skip=false" >> "$GITHUB_OUTPUT" | |
| # Nightly blocking relies on TWO mechanisms that must BOTH be preserved (see hub job below): | |
| # 1. gate-skip == 'true' (a legitimate "not green yet" skip; gate job succeeds), and | |
| # 2. job-dependency skip on plan FAILURE. | |
| # The cross-test gate + version oracle live in the reusable release-gate-plan | |
| # workflow (one source of truth, shared with nightly-dry-run.yml). This `plan` | |
| # job runs it for the real nightly. Outputs: sha, gate-skip, version, kind, | |
| # nothing-pending. | |
| plan: | |
| needs: [validate] | |
| if: needs.validate.outputs.release-type == 'nightly' && needs.validate.outputs.skip != 'true' | |
| uses: ./.github/workflows/release-gate-plan.yml | |
| with: | |
| ref: ${{ needs.validate.outputs.ref }} | |
| secrets: inherit | |
| release-ios: | |
| needs: [validate, plan] | |
| if: >- | |
| !cancelled() && !contains(needs.*.result, 'failure') | |
| && needs.validate.outputs.ios == 'true' && needs.validate.outputs.skip != 'true' | |
| && needs.validate.outputs.dry-run != 'true' | |
| && (needs.validate.outputs.release-type != 'nightly' | |
| || (needs.plan.outputs.gate-skip != 'true' && needs.plan.outputs.nothing-pending != 'true')) | |
| uses: ./.github/workflows/release-ios.yml | |
| with: | |
| release-type: ${{ needs.validate.outputs.release-type }} | |
| rc-number: ${{ inputs.rc-number }} | |
| ref: ${{ needs.validate.outputs.ref }} | |
| pending-version: ${{ needs.plan.outputs.version }} | |
| pending-kind: ${{ needs.plan.outputs.kind }} | |
| secrets: inherit | |
| release-android: | |
| needs: [validate, plan] | |
| if: >- | |
| !cancelled() && !contains(needs.*.result, 'failure') | |
| && needs.validate.outputs.android == 'true' && needs.validate.outputs.skip != 'true' | |
| && needs.validate.outputs.dry-run != 'true' | |
| && (needs.validate.outputs.release-type != 'nightly' | |
| || (needs.plan.outputs.gate-skip != 'true' && needs.plan.outputs.nothing-pending != 'true')) | |
| uses: ./.github/workflows/release-android.yml | |
| with: | |
| release-type: ${{ needs.validate.outputs.release-type }} | |
| rc-number: ${{ inputs.rc-number }} | |
| ref: ${{ needs.validate.outputs.ref }} | |
| pending-version: ${{ needs.plan.outputs.version }} | |
| pending-kind: ${{ needs.plan.outputs.kind }} | |
| secrets: inherit | |
| release-node-sdk: | |
| needs: [validate, plan] | |
| if: >- | |
| !cancelled() && !contains(needs.*.result, 'failure') | |
| && needs.validate.outputs.node-sdk == 'true' && needs.validate.outputs.skip != 'true' | |
| && (needs.validate.outputs.release-type != 'nightly' | |
| || (needs.plan.outputs.gate-skip != 'true' && needs.plan.outputs.nothing-pending != 'true')) | |
| uses: ./.github/workflows/release-node-sdk.yml | |
| with: | |
| release-type: ${{ needs.validate.outputs.release-type }} | |
| rc-number: ${{ inputs.rc-number }} | |
| ref: ${{ needs.validate.outputs.ref }} | |
| pending-version: ${{ needs.plan.outputs.version }} | |
| pending-kind: ${{ needs.plan.outputs.kind }} | |
| dry-run: ${{ needs.validate.outputs.dry-run == 'true' }} | |
| secrets: inherit | |
| release-browser-sdk: | |
| needs: [validate, plan] | |
| if: >- | |
| !cancelled() && !contains(needs.*.result, 'failure') | |
| && needs.validate.outputs.browser-sdk == 'true' && needs.validate.outputs.skip != 'true' | |
| && (needs.validate.outputs.release-type != 'nightly' | |
| || (needs.plan.outputs.gate-skip != 'true' && needs.plan.outputs.nothing-pending != 'true')) | |
| uses: ./.github/workflows/release-browser-sdk.yml | |
| with: | |
| release-type: ${{ needs.validate.outputs.release-type }} | |
| rc-number: ${{ inputs.rc-number }} | |
| ref: ${{ needs.validate.outputs.ref }} | |
| pending-version: ${{ needs.plan.outputs.version }} | |
| pending-kind: ${{ needs.plan.outputs.kind }} | |
| dry-run: ${{ needs.validate.outputs.dry-run == 'true' }} | |
| secrets: inherit | |
| merge-pr: | |
| # dry-run must not mutate the repo: a final + dry-run would otherwise merge | |
| # and delete the release branch even though the release-* jobs were skipped | |
| # (skipped ≠ failure, so the !contains(failure) check alone lets it through). | |
| if: needs.validate.outputs.release-type == 'final' && !inputs.no-merge && !cancelled() && !contains(needs.*.result, 'failure') && needs.validate.outputs.dry-run != 'true' | |
| needs: [validate, release-ios, release-android, release-node-sdk, release-browser-sdk] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| - name: Merge release PR | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| RELEASE_BRANCH: ${{ needs.validate.outputs.ref }} | |
| run: | | |
| BRANCH="${RELEASE_BRANCH#refs/heads/}" | |
| PR_NUMBER=$(gh pr list --head "$BRANCH" --base main --json number --jq '.[0].number') | |
| if [ -z "$PR_NUMBER" ]; then | |
| echo "No PR found for branch $BRANCH targeting main" | |
| exit 1 | |
| fi | |
| gh pr merge "$PR_NUMBER" --merge --delete-branch | |
| # Nightly blocking + non-nightly compatibility rely on the `if` pattern shared | |
| # by the release-* jobs and this hub job: | |
| # !cancelled() && !contains(needs.*.result, 'failure') && <type/gate clauses> | |
| # Why each piece matters: | |
| # - `!cancelled()` forces the `if` to be EVALUATED even when a `needs` job was | |
| # SKIPPED. Without it, gate/pending being skipped for dev/rc/final auto-skips | |
| # every release-* job (GitHub's implicit success() on needs) — which silently | |
| # breaks all non-nightly releases. | |
| # - `!contains(needs.*.result, 'failure')` still blocks when a needs job (e.g. a | |
| # nightly gate error) FAILED — fail-closed for nightlies. | |
| # - the `gate-skip != 'true'` clause blocks a nightly when cross-test isn't green. | |
| # Do NOT drop any of the three; they jointly make non-nightly run and nightly | |
| # block-on-not-green / block-on-error. | |
| hub: | |
| needs: [validate, plan, release-ios, release-android, release-node-sdk, release-browser-sdk] | |
| if: >- | |
| needs.validate.outputs.release-type == 'nightly' | |
| && needs.plan.outputs.gate-skip != 'true' | |
| && needs.plan.outputs.nothing-pending != 'true' | |
| && needs.validate.outputs.dry-run != 'true' | |
| && !cancelled() && !contains(needs.*.result, 'failure') | |
| # Serialize hub runs for the same pending version so two overlapping nightly | |
| # runs (e.g. cron + a manual dispatch) can't both create the same tag/release | |
| # and race on `gh release create`. Scoped to the pending version (resolved by | |
| # the `plan` job); cancel-in-progress: false so a second run waits rather | |
| # than clobbering an in-flight tag/release. | |
| concurrency: | |
| group: hub-release-${{ needs.plan.outputs.version }} | |
| cancel-in-progress: false | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ needs.validate.outputs.ref }} | |
| fetch-depth: 0 | |
| - uses: ./.github/actions/setup-release-tools | |
| # Don't mint an empty hub release. A manual nightly dispatch can disable | |
| # every SDK (ios=android=node=wasm=false), which would otherwise produce a | |
| # libxmtp Release with an all-"—" rollup. Require at least one SDK to have | |
| # shipped a version. (The cron path always enables all four, so this only | |
| # guards manual dispatch.) | |
| - name: Guard against empty rollup | |
| id: guard | |
| env: | |
| IOS_VERSION: ${{ needs.release-ios.outputs.version }} | |
| ANDROID_VERSION: ${{ needs.release-android.outputs.version }} | |
| NODE_VERSION: ${{ needs.release-node-sdk.outputs.binding-version }} | |
| WASM_VERSION: ${{ needs.release-browser-sdk.outputs.binding-version }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "${IOS_VERSION}${ANDROID_VERSION}${NODE_VERSION}${WASM_VERSION}" ]; then | |
| echo "should-release=false" >> "$GITHUB_OUTPUT" | |
| echo "::notice::No SDK shipped a version; skipping the empty hub release." | |
| else | |
| echo "should-release=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Compute libxmtp hub version | |
| id: hubver | |
| if: steps.guard.outputs.should-release == 'true' | |
| run: | | |
| set -euo pipefail | |
| VERSION=$(xmtp-release resolve-sdk-version \ | |
| --sdk libxmtp --release-type nightly \ | |
| --pending-version "${{ needs.plan.outputs.version }}" \ | |
| --pending-kind "${{ needs.plan.outputs.kind }}") | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| - name: Install git-cliff | |
| if: steps.guard.outputs.should-release == 'true' | |
| run: | | |
| set -euo pipefail | |
| cargo binstall -y git-cliff --version 2.13.1 \ | |
| || cargo install git-cliff --version 2.13.1 --locked | |
| git cliff --version | |
| # Per-nightly changelog: the delta since the PREVIOUS nightly tag (not | |
| # since the last stable). Computed BEFORE tagging this run, so the newest | |
| # existing v*-nightly.* tag is genuinely the prior nightly. First-ever | |
| # nightly (no prior nightly tag) falls back to since-last-stable. | |
| - name: Compute per-nightly changelog | |
| id: nightly_changelog | |
| if: steps.guard.outputs.should-release == 'true' | |
| run: | | |
| set -euo pipefail | |
| PREV_NIGHTLY=$(git tag -l 'v*-nightly.*' --sort=-creatordate | head -1 || true) | |
| if [ -n "$PREV_NIGHTLY" ]; then | |
| echo "::notice::Changelog range ${PREV_NIGHTLY}..HEAD" | |
| git cliff --config cliff.toml "${PREV_NIGHTLY}..HEAD" --strip header > /tmp/nightly-changelog.md 2>/dev/null || true | |
| else | |
| echo "::notice::No prior nightly tag; changelog falls back to since-last-stable." | |
| git cliff --config cliff.toml --unreleased --strip header > /tmp/nightly-changelog.md 2>/dev/null || true | |
| fi | |
| # Collapse to empty if git-cliff produced only whitespace/heading noise. | |
| if ! grep -qE '^- ' /tmp/nightly-changelog.md; then | |
| : > /tmp/nightly-changelog.md | |
| fi | |
| - name: Tag libxmtp hub | |
| if: steps.guard.outputs.should-release == 'true' | |
| run: xmtp-release tag-release --sdk libxmtp --version "${{ steps.hubver.outputs.version }}" --ignore-if-exists | |
| - name: Create hub GitHub Release | |
| if: steps.guard.outputs.should-release == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| HUB_VERSION: ${{ steps.hubver.outputs.version }} | |
| IOS_VERSION: ${{ needs.release-ios.outputs.version }} | |
| ANDROID_VERSION: ${{ needs.release-android.outputs.version }} | |
| NODE_VERSION: ${{ needs.release-node-sdk.outputs.binding-version }} | |
| WASM_VERSION: ${{ needs.release-browser-sdk.outputs.binding-version }} | |
| run: | | |
| set -euo pipefail | |
| BODY=$(cat <<EOF | |
| ## libxmtp ${HUB_VERSION} | |
| Nightly release. SDKs shipped from this libxmtp release: | |
| | SDK | Version | | |
| | --- | --- | | |
| | iOS | ${IOS_VERSION:-—} | | |
| | Android | ${ANDROID_VERSION:-—} | | |
| | Node | ${NODE_VERSION:-—} | | |
| | WASM | ${WASM_VERSION:-—} | | |
| See per-SDK release notes under \`docs/release-notes/\`. | |
| EOF | |
| ) | |
| # Append the per-nightly changelog (delta since the previous nightly), | |
| # if git-cliff produced any entries. | |
| if [ -s /tmp/nightly-changelog.md ]; then | |
| BODY="${BODY} | |
| ## Changes since the last nightly | |
| $(cat /tmp/nightly-changelog.md)" | |
| fi | |
| if gh release view "v${HUB_VERSION}" >/dev/null 2>&1; then | |
| gh release edit "v${HUB_VERSION}" \ | |
| --title "libxmtp ${HUB_VERSION}" \ | |
| --notes "$BODY" \ | |
| --prerelease | |
| else | |
| gh release create "v${HUB_VERSION}" \ | |
| --title "libxmtp ${HUB_VERSION}" \ | |
| --notes "$BODY" \ | |
| --prerelease | |
| fi | |
| notify: | |
| needs: [validate, plan, hub, release-ios, release-android, release-node-sdk, release-browser-sdk, merge-pr] | |
| if: always() && needs.validate.outputs.skip != 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - id: compute | |
| env: | |
| IOS_RESULT: ${{ needs.release-ios.result }} | |
| IOS_VERSION: ${{ needs.release-ios.outputs.version }} | |
| ANDROID_RESULT: ${{ needs.release-android.result }} | |
| ANDROID_VERSION: ${{ needs.release-android.outputs.version }} | |
| NODE_RESULT: ${{ needs.release-node-sdk.result }} | |
| NODE_VERSION: ${{ needs.release-node-sdk.outputs.binding-version }} | |
| WASM_RESULT: ${{ needs.release-browser-sdk.result }} | |
| WASM_VERSION: ${{ needs.release-browser-sdk.outputs.binding-version }} | |
| RELEASE_TYPE: ${{ needs.validate.outputs.release-type }} | |
| run: | | |
| MSG="${RELEASE_TYPE^} Release:" | |
| if [ -n "$IOS_VERSION" ] || [ "$IOS_RESULT" != "skipped" ]; then | |
| MSG="$MSG iOS ${IOS_VERSION:-failed} (${IOS_RESULT})" | |
| fi | |
| if [ -n "$ANDROID_VERSION" ] || [ "$ANDROID_RESULT" != "skipped" ]; then | |
| MSG="$MSG Android ${ANDROID_VERSION:-failed} (${ANDROID_RESULT})" | |
| fi | |
| if [ -n "$NODE_VERSION" ] || [ "$NODE_RESULT" != "skipped" ]; then | |
| MSG="$MSG Node ${NODE_VERSION:-failed} (${NODE_RESULT})" | |
| fi | |
| if [ -n "$WASM_VERSION" ] || [ "$WASM_RESULT" != "skipped" ]; then | |
| MSG="$MSG WASM ${WASM_VERSION:-failed} (${WASM_RESULT})" | |
| fi | |
| echo "message=$MSG" >> "$GITHUB_OUTPUT" | |
| - uses: ./.github/actions/slack-notify | |
| with: | |
| message: ${{ steps.compute.outputs.message }} | |
| webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }} |