From 87e77bf4aea6a1d94832f2c37874fd5ee51949cc Mon Sep 17 00:00:00 2001 From: Yan Xue Date: Wed, 20 May 2026 20:48:00 -0700 Subject: [PATCH] feat(typescript-service-release): add notify-on-release email pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the email-monkey notify job into the public buildspace so PUBLIC caller repos (e.g. photon-hq/spectrum-ts) can get release-notification emails through plain `uses:` without needing the public->internal App-token + REST-dispatch workaround. Three coupled additions, atomic because notify needs release_type: 1. .github/blocks/determine-publish-version/action.yaml - new `release-type` output classifying the bump (major|minor|patch). - Get-last-release step now fetches a page of recent releases and exposes BOTH `version` (latest of any kind — used for the AI commit range) and `stable_version` (latest non-prerelease — used for classification). Classifying against the last STABLE means a GA finalization of an -rc (e.g. 2.0.0-rc.3 -> 2.0.0) correctly emits `major` instead of silently downgrading to `patch`. - Classify Bump strips any -rc.N suffix on NEXT before comparing against PREV (which is already stable). 2. .github/blocks/generate-release-info/action.yaml - forwards `release_type` from the underlying version block. 3. .github/workflows/typescript-service-release.yaml - new `notify-on-release` input (default true). Description spells out the actual gate: fires on MAJOR releases of allow-listed callers only; minor/patch and non-allow-listed callers skip cleanly. - new optional `TS_OAUTH_CLIENT_ID` / `TS_OAUTH_SECRET` secrets (Tailscale OAuth for ephemeral tailnet join as tag:ci). - `release-info` job exposes `release_type` for downstream jobs. - new `notify` job: gates on caller == photon-hq/spectrum-ts AND release_type == 'major'. Body joins tailnet as tag:ci, POSTs {repo, version, releaseNotes, releaseType, serviceName} to http://email-monkey/notify-release. Onboard additional callers by extending the github.repository check. Patch releases short-circuit; non-allowlisted callers short-circuit; missing TS_OAUTH_* secrets fail loudly on the tailnet join step (intentional, since you can't reach email-monkey without them). Co-authored-by: Cursor --- .../determine-publish-version/action.yaml | 62 +++++++++++++++- .../blocks/generate-release-info/action.yaml | 3 + .../workflows/typescript-service-release.yaml | 73 +++++++++++++++++++ 3 files changed, 134 insertions(+), 4 deletions(-) diff --git a/.github/blocks/determine-publish-version/action.yaml b/.github/blocks/determine-publish-version/action.yaml index 45ce469..086c686 100644 --- a/.github/blocks/determine-publish-version/action.yaml +++ b/.github/blocks/determine-publish-version/action.yaml @@ -17,6 +17,9 @@ outputs: previous-version: description: 'The previous version before this release' value: ${{ steps.last-release.outputs.version }} + release-type: + description: 'The bump type relative to the last STABLE (non-prerelease) release: major | minor | patch. Comparing against the last stable, not the last release-of-any-kind, so GA finalization after a prerelease (2.0.0-rc.3 -> 2.0.0) is correctly classified as the original bump (here, major) instead of patch.' + value: ${{ steps.classify.outputs.kind }} runs: using: 'composite' @@ -27,14 +30,23 @@ runs: with: script: | try { + // Pull a page of recent releases so we can pick out both the + // last release of any kind (used for the AI commit range — + // the AI should only describe NEW changes since the previous + // tag, prerelease or not) and the last STABLE release (used + // for semver classification — a GA finalization of an -rc + // should classify as the original bump kind, not patch). const { data: releases } = await github.rest.repos.listReleases({ owner: context.repo.owner, repo: context.repo.repo, - per_page: 1 + per_page: 100 }); - - if (releases.length > 0) { - const tagName = releases[0].tag_name; + const nonDraft = releases.filter(r => !r.draft); + const latest = nonDraft[0]; + const lastStable = nonDraft.find(r => !r.prerelease); + + if (latest) { + const tagName = latest.tag_name; const { data: ref } = await github.rest.git.getRef({ owner: context.repo.owner, repo: context.repo.repo, @@ -51,9 +63,17 @@ runs: core.setOutput('sha', allCommits[allCommits.length - 1].sha); core.setOutput('version', '0.0.0'); } + + // For classification: prefer the last stable. Falls back to + // `0.0.0` if no stable release exists yet (initial-release path). + core.setOutput( + 'stable_version', + lastStable ? lastStable.tag_name.replace(/^v/, '') : '0.0.0' + ); } catch (error) { core.setOutput('sha', context.sha); core.setOutput('version', '0.0.0'); + core.setOutput('stable_version', '0.0.0'); } - name: AI Determine Version @@ -102,3 +122,37 @@ runs: fi echo "Determined version: ${NEXT}" + + - name: Classify Bump + id: classify + shell: bash + env: + # Use the last STABLE version (not last-of-any-kind) so that + # finalizing a prerelease into GA (e.g. 2.0.0-rc.3 -> 2.0.0) + # classifies as the original bump (major here), not patch. + # Without this, prev_core == next_core after stripping suffixes + # and the GA's notify path silently downgrades to patch. + PREV: ${{ steps.last-release.outputs.stable_version }} + NEXT: ${{ steps.version.outputs.final }} + run: | + # Strip any pre-release suffix (e.g. -rc.5) on NEXT before + # comparing. PREV is already stable so it has no suffix. + prev_core="${PREV%%-*}" + next_core="${NEXT%%-*}" + + IFS='.' read -r p_major p_minor p_patch <<< "$prev_core" + IFS='.' read -r n_major n_minor n_patch <<< "$next_core" + + : "${p_major:=0}"; : "${p_minor:=0}"; : "${p_patch:=0}" + : "${n_major:=0}"; : "${n_minor:=0}"; : "${n_patch:=0}" + + if [ "$n_major" -gt "$p_major" ]; then + kind="major" + elif [ "$n_minor" -gt "$p_minor" ]; then + kind="minor" + else + kind="patch" + fi + + echo "kind=$kind" >> "$GITHUB_OUTPUT" + echo "Classified bump: $prev_core -> $next_core => $kind" diff --git a/.github/blocks/generate-release-info/action.yaml b/.github/blocks/generate-release-info/action.yaml index 5312bd1..36f1d53 100644 --- a/.github/blocks/generate-release-info/action.yaml +++ b/.github/blocks/generate-release-info/action.yaml @@ -20,6 +20,9 @@ outputs: release_notes: description: 'AI-generated release notes in markdown' value: ${{ steps.ai-notes.outputs.final-message }} + release_type: + description: 'The bump type relative to previous-version: major | minor | patch' + value: ${{ steps.version-info.outputs.release-type }} runs: using: 'composite' diff --git a/.github/workflows/typescript-service-release.yaml b/.github/workflows/typescript-service-release.yaml index ca69636..7c77169 100644 --- a/.github/workflows/typescript-service-release.yaml +++ b/.github/workflows/typescript-service-release.yaml @@ -67,6 +67,11 @@ on: required: false default: false description: "Opt in to npm OIDC Trusted Publishing. When true, the publish job inherits the caller's GITHUB_TOKEN so it can use 'id-token: write' (the caller must grant it) and a trusted publisher must be configured on npmjs.com. Falls back to NPM_TOKEN if OIDC is unavailable. When false (default), publishing uses NPM_TOKEN and the job keeps least-privilege 'contents: read'." + notify-on-release: + type: boolean + required: false + default: true + description: "Fire the post-release notify job on real releases (major | minor | patch). Callers not on the in-job allow-list skip cleanly. Set to false to opt a specific release out." secrets: OPENAI_API_KEY: required: true @@ -80,6 +85,12 @@ on: APP_PRIVATE_KEY: required: false description: "GitHub App private key for pushing to protected branches and triggering downstream workflows" + TS_OAUTH_CLIENT_ID: + required: false + description: "Tailscale OAuth client ID — used by the notify job to join the tailnet ephemerally" + TS_OAUTH_SECRET: + required: false + description: "Tailscale OAuth client secret matching TS_OAUTH_CLIENT_ID" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -109,6 +120,7 @@ jobs: outputs: version: ${{ steps.generate.outputs.version }} release_notes: ${{ steps.generate.outputs.release_notes }} + release_type: ${{ steps.generate.outputs.release_type }} steps: - uses: actions/checkout@v5 with: @@ -209,3 +221,64 @@ jobs: publish-command: ${{ inputs.publish-command }} dry-run: ${{ inputs.dry-run }} npm-token: ${{ secrets.NPM_TOKEN }} + + notify: + needs: [check-labels, release-info, github-release, npm-publish, npm-publish-oidc] + if: >- + always() && + github.repository == 'photon-hq/spectrum-ts' && + (fromJSON(needs.check-labels.outputs.labels).release || inputs.release) && + needs.release-info.result == 'success' && + needs.github-release.result == 'success' && + (needs.npm-publish.result == 'success' || needs.npm-publish.result == 'skipped') && + (needs.npm-publish-oidc.result == 'success' || needs.npm-publish-oidc.result == 'skipped') && + inputs.notify-on-release && + contains(fromJSON('["major","minor","patch"]'), needs.release-info.outputs.release_type) + runs-on: ${{ inputs.use-blacksmith && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} + permissions: + contents: read + steps: + - name: Join Tailnet + uses: tailscale/github-action@v3 + with: + oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} + oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} + tags: tag:infra + version: latest + + - name: POST to email-monkey + env: + SERVICE_NAME: ${{ inputs.service-name }} + REPO: ${{ github.repository }} + VERSION: ${{ needs.release-info.outputs.version }} + RELEASE_NOTES: ${{ needs.release-info.outputs.release_notes }} + RELEASE_TYPE: ${{ needs.release-info.outputs.release_type }} + shell: bash + run: | + set -euo pipefail + BODY="$(jq -nc \ + --arg repo "$REPO" \ + --arg version "$VERSION" \ + --arg releaseNotes "$RELEASE_NOTES" \ + --arg releaseType "$RELEASE_TYPE" \ + --arg serviceName "$SERVICE_NAME" \ + '{repo:$repo, version:$version, releaseNotes:$releaseNotes, releaseType:$releaseType, serviceName:$serviceName}')" + + echo "POSTing to email-monkey for $REPO (release_type=$RELEASE_TYPE, version=$VERSION)" + + HTTP_CODE="$(curl --silent --show-error \ + --output /tmp/email-monkey.out \ + --write-out '%{http_code}' \ + --max-time 60 \ + --request POST "http://email-monkey/notify-release" \ + --header "Content-Type: application/json" \ + --data "$BODY")" + + echo "email-monkey response ($HTTP_CODE):" + cat /tmp/email-monkey.out || true + echo + + if [ "$HTTP_CODE" -ge 400 ]; then + echo "::error::email-monkey returned HTTP $HTTP_CODE" + exit 1 + fi