Draft a new release #212
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: Draft a new release | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| release_ticket_id: | |
| description: Release ticket ID (Ex:- SDK-1234) | |
| required: true | |
| slack_message_link: | |
| description: Slack message URL of the release | |
| required: true | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| NODE_OPTIONS: '--no-warnings' | |
| permissions: | |
| contents: read | |
| jobs: | |
| validate-actor: | |
| permissions: | |
| contents: read | |
| # Only allow to draft a new release from develop and hotfix branches | |
| if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/heads/hotfix/') | |
| uses: ./.github/workflows/validate-actor.yml | |
| with: | |
| team_names: 'sdk_team,integrations' | |
| secrets: | |
| RELEASE_PRIVATE_KEY: ${{ secrets.RELEASE_PRIVATE_KEY }} | |
| draft-new-release: | |
| needs: validate-actor | |
| name: Draft a new release | |
| runs-on: [self-hosted, Linux, X64] | |
| permissions: | |
| contents: read # to checkout repository code | |
| steps: | |
| - name: Harden the runner (Audit all outbound calls) | |
| uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 | |
| with: | |
| egress-policy: audit | |
| - name: Generate GitHub App Token | |
| id: generate-token | |
| uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 | |
| with: | |
| app-id: ${{ vars.RELEASE_APP_ID }} | |
| private-key: ${{ secrets.RELEASE_PRIVATE_KEY }} | |
| permission-contents: write # to create commits, tags, and push branches | |
| permission-pull-requests: write # to create and update PRs | |
| - name: Checkout | |
| uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | |
| with: | |
| fetch-depth: 0 | |
| token: ${{ steps.generate-token.outputs.token }} | |
| - name: Setup Node | |
| uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | |
| with: | |
| node-version-file: '.nvmrc' | |
| cache: 'npm' | |
| - name: Setup workspace | |
| env: | |
| HUSKY: 0 | |
| run: | | |
| npm run setup:deps | |
| # In order to make a commit, we need to initialize a user. | |
| # You may choose to write something less generic here if you want, it doesn't matter functionality wise. | |
| - name: Initialize mandatory git config | |
| run: | | |
| git config user.name "GitHub actions" | |
| git config user.email noreply@github.com | |
| # Calculate the next release version based on conventional semantic release | |
| - name: Create release branch | |
| id: create-release | |
| env: | |
| HUSKY: 0 | |
| RELEASE_TICKET_ID: ${{ github.event.inputs.release_ticket_id }} | |
| run: | | |
| source_branch_name=${GITHUB_REF#refs/heads/} | |
| release_type=release | |
| grep -q "hotfix/" <<< "${GITHUB_REF}" && release_type=hotfix-release | |
| git fetch origin main | |
| git fetch --tags origin | |
| git merge origin/main | |
| current_version=$(jq -r .version package.json) | |
| npm run bump-version:monorepo | |
| new_version=$(jq -r .version package.json) | |
| git reset --hard | |
| branch_name="${release_type}/${new_version}-${RELEASE_TICKET_ID}" | |
| echo "Source branch for new release is $source_branch_name" | |
| echo "Current version is $current_version" | |
| echo "Release type is $release_type" | |
| echo "New version is $new_version" | |
| echo "New release branch name is $branch_name" | |
| git checkout -b "$branch_name" | |
| echo "source_branch_name=$source_branch_name" >> $GITHUB_OUTPUT | |
| echo "branch_name=$branch_name" >> $GITHUB_OUTPUT | |
| echo "new_version=$new_version" >> $GITHUB_OUTPUT | |
| echo "CURRENT_VERSION_VALUE=$current_version" >> $GITHUB_ENV | |
| echo "NEW_VERSION_VALUE=$new_version" >> $GITHUB_ENV | |
| - name: Create release branch on remote | |
| env: | |
| GH_TOKEN: ${{ steps.generate-token.outputs.token }} | |
| BRANCH_NAME: ${{ steps.create-release.outputs.branch_name }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| # The signed-commit action requires the branch to exist on the remote. | |
| # Idempotent: update ref if it exists, create if it doesn't. | |
| sha=$(git rev-parse HEAD) | |
| if gh api "repos/${REPO}/git/ref/heads/${BRANCH_NAME}" >/dev/null 2>&1; then | |
| gh api "repos/${REPO}/git/refs/heads/${BRANCH_NAME}" \ | |
| --method PATCH \ | |
| -f "sha=${sha}" \ | |
| -F "force=true" | |
| else | |
| gh api "repos/${REPO}/git/refs" \ | |
| --method POST \ | |
| -f "ref=refs/heads/${BRANCH_NAME}" \ | |
| -f "sha=${sha}" | |
| fi | |
| - name: Update changelog & bump version | |
| id: finish-release | |
| env: | |
| HUSKY: 0 | |
| BASE_BRANCH: ${{ steps.create-release.outputs.branch_name }} | |
| run: | | |
| echo "Current version: $CURRENT_VERSION_VALUE" | |
| echo "New version: $NEW_VERSION_VALUE" | |
| git fetch --no-tags --prune --depth=100 origin main | |
| npm run release -- --skipCommit=true | |
| ./scripts/sync-tags-in-nx-projects.sh | |
| ./scripts/generate-last-release-changelog.sh | |
| npm run bump-version:monorepo | |
| npx replace $CURRENT_VERSION_VALUE $NEW_VERSION_VALUE sonar-project.properties | |
| npm install | |
| - name: Create verified commit and push changes to release branch | |
| uses: ryancyq/github-signed-commit@6afa32d8aa9819faddc248e6258e9c11ec05ecc8 # v1.4.0 | |
| env: | |
| GH_TOKEN: ${{ steps.generate-token.outputs.token }} | |
| with: | |
| branch-name: ${{ steps.create-release.outputs.branch_name }} | |
| commit-message: 'chore: update versions and generate release changelog' | |
| files: | | |
| . | |
| # jscutlery runs with --skipCommit=true, so it writes version/changelog files but | |
| # creates no commits or tags. The signed-commit action above packages all file | |
| # changes into one verified commit on the release branch. This step then creates | |
| # annotated tag objects (via the Git Data API) for each bumped package at that | |
| # commit, and points tag refs at them. | |
| # | |
| # Tag objects are unsigned. GitHub's Git Data API for `POST /git/tags` does not | |
| # accept a signature in its payload; producing a signed tag would require running | |
| # `git tag -s` locally with a signing key provisioned as a workflow secret, which | |
| # is out of scope. Accepted tradeoff: tag objects show "Unverified" while the | |
| # target commit remains verified. Same behavior as the monorepo v<version> tag | |
| # in publish-new-release.yml. | |
| # | |
| # Affected-package filter: a package gets a new tag only if its package.json | |
| # `version` differs between the release branch HEAD and origin/main. Unchanged | |
| # packages are skipped. | |
| # | |
| # Idempotence: if the tag already exists on origin, dereference it and only skip | |
| # when it already points at the current release_sha. If it points elsewhere, fail | |
| # loudly so we don't quietly leave a mispointed tag in place (that class of bug is | |
| # exactly what caused the cc04066f-vs-bc75c04 mismatch in the previous release). | |
| - name: Push release tags to release branch HEAD | |
| env: | |
| GH_TOKEN: ${{ steps.generate-token.outputs.token }} | |
| BRANCH_NAME: ${{ steps.create-release.outputs.branch_name }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| git fetch origin "$BRANCH_NAME" | |
| git fetch --no-tags origin main | |
| release_sha=$(git rev-parse "origin/$BRANCH_NAME") | |
| echo "Release branch HEAD: $release_sha" | |
| for pj in packages/*/project.json; do | |
| tag=$(jq -r '.targets.github.options.tag // empty' "$pj") | |
| [ -z "$tag" ] && continue | |
| pkg_dir=$(dirname "$pj") | |
| current_version=$(git show "origin/${BRANCH_NAME}:${pkg_dir}/package.json" 2>/dev/null | jq -r '.version // empty' 2>/dev/null || echo "") | |
| prev_version=$(git show "origin/main:${pkg_dir}/package.json" 2>/dev/null | jq -r '.version // empty' 2>/dev/null || echo "") | |
| if [ -z "$current_version" ]; then | |
| echo "::error::Unable to read version for ${pkg_dir}; refusing to tag ${tag}" | |
| exit 1 | |
| fi | |
| if [ -n "$prev_version" ] && [ "$current_version" = "$prev_version" ]; then | |
| echo "Skipping ${tag} — version unchanged vs origin/main" | |
| continue | |
| fi | |
| encoded=$(printf '%s' "$tag" | jq -sRr @uri) | |
| if existing_ref=$(gh api "repos/${REPO}/git/ref/tags/${encoded}" 2>/dev/null); then | |
| existing_type=$(printf '%s' "$existing_ref" | jq -r '.object.type') | |
| existing_obj=$(printf '%s' "$existing_ref" | jq -r '.object.sha') | |
| if [ "$existing_type" = "tag" ]; then | |
| existing_commit=$(gh api "repos/${REPO}/git/tags/${existing_obj}" --jq '.object.sha') | |
| else | |
| existing_commit="$existing_obj" | |
| fi | |
| if [ "$existing_commit" = "$release_sha" ]; then | |
| echo "Tag ${tag} already points at ${release_sha}, skipping" | |
| continue | |
| fi | |
| echo "::error::Tag ${tag} exists on origin but points at ${existing_commit} (expected ${release_sha})" | |
| exit 1 | |
| fi | |
| echo "Creating annotated tag ${tag} -> ${release_sha}" | |
| pkg_scope="${tag%@*}" | |
| pkg_version="${tag##*@}" | |
| tag_obj_sha=$(gh api --method POST "repos/${REPO}/git/tags" \ | |
| -f "tag=${tag}" \ | |
| -f "message=chore(${pkg_scope}): release version ${pkg_version}" \ | |
| -f "object=${release_sha}" \ | |
| -f "type=commit" \ | |
| --jq '.sha') | |
| gh api --method POST "repos/${REPO}/git/refs" \ | |
| -f "ref=refs/tags/${tag}" \ | |
| -f "sha=${tag_obj_sha}" | |
| done | |
| - name: Generate packages versions table | |
| id: packages-versions | |
| run: | | |
| chmod +x ./scripts/collect-packages-versions.sh | |
| ./scripts/collect-packages-versions.sh > packages_versions.md | |
| - name: Create pull request into main | |
| id: create-pr | |
| env: | |
| GH_TOKEN: ${{ steps.generate-token.outputs.token }} | |
| BRANCH_NAME: ${{ steps.create-release.outputs.branch_name }} | |
| RELEASE_TICKET_ID: ${{ github.event.inputs.release_ticket_id }} | |
| run: | | |
| pr_title="chore(release): merge ${BRANCH_NAME} into main [${RELEASE_TICKET_ID}]" | |
| # Create PR body with proper formatting | |
| cat > pr_body.md << EOF | |
| :crown: **Automated Release PR** | |
| This pull request was created automatically by the GitHub Actions workflow. It merges the release branch (\`${BRANCH_NAME}\`) into the \`main\` branch. | |
| This ensures that the latest release branch changes are incorporated into the \`main\` branch for production. | |
| ### Details | |
| - **Monorepo Release Version**: v${{ env.NEW_VERSION_VALUE }} | |
| - **Release Branch**: \`${BRANCH_NAME}\` | |
| - **Related Ticket**: [${RELEASE_TICKET_ID}](https://linear.app/rudderstack/issue/${RELEASE_TICKET_ID}) | |
| ### Version updates | |
| $(cat packages_versions.md) | |
| --- | |
| Please review and merge when ready. :rocket: | |
| EOF | |
| # Create the PR using GitHub CLI and capture the URL | |
| pr_url=$(gh pr create \ | |
| --base main \ | |
| --head "$BRANCH_NAME" \ | |
| --title "$pr_title" \ | |
| --body-file pr_body.md) | |
| echo "pr_url=$pr_url" >> $GITHUB_OUTPUT | |
| - name: Extract Slack channel and thread info | |
| id: slack-info | |
| continue-on-error: true | |
| env: | |
| SLACK_MESSAGE_LINK: ${{ github.event.inputs.slack_message_link }} | |
| run: | | |
| link="${SLACK_MESSAGE_LINK}" | |
| # Extract channel ID from the link (after /archives/ and before the next /) | |
| channel_id=$(echo "$link" | sed -n 's|.*\/archives\/\([^\/]*\)\/.*|\1|p') | |
| # Check if thread_ts parameter exists in URL (for messages within a thread) | |
| if echo "$link" | grep -q "thread_ts="; then | |
| # Extract thread_ts parameter value | |
| thread_ts=$(echo "$link" | sed -n 's|.*thread_ts=\([0-9]*\.[0-9]*\).*|\1|p') | |
| echo "Using thread_ts from URL parameter: $thread_ts" | |
| else | |
| # Extract timestamp from /p part and convert to timestamp format (for parent messages) | |
| thread_ts=$(echo "$link" | sed -n 's|.*\/p\([0-9]*\).*|\1|p') | |
| # Convert from Slack link format (p1640995200123456) to timestamp format (1640995200.123456) | |
| if [ ${#thread_ts} -eq 16 ]; then | |
| # Split into seconds and microseconds parts | |
| seconds=${thread_ts:0:10} | |
| microseconds=${thread_ts:10:6} | |
| thread_ts="${seconds}.${microseconds}" | |
| fi | |
| echo "Using timestamp from /p part: $thread_ts" | |
| fi | |
| echo "Extracted channel ID: $channel_id" | |
| echo "Final thread timestamp: $thread_ts" | |
| echo "channel_id=$channel_id" >> $GITHUB_OUTPUT | |
| echo "thread_ts=$thread_ts" >> $GITHUB_OUTPUT | |
| - name: Create release info artifact | |
| if: ${{ steps.slack-info.outputs.thread_ts != '' && steps.slack-info.outputs.channel_id != '' }} | |
| env: | |
| CHANNEL_ID: ${{ steps.slack-info.outputs.channel_id }} | |
| THREAD_TS: ${{ steps.slack-info.outputs.thread_ts }} | |
| PR_URL: ${{ steps.create-pr.outputs.pr_url }} | |
| NEW_VERSION_VALUE: ${{ env.NEW_VERSION_VALUE }} | |
| RELEASE_BRANCH: ${{ steps.create-release.outputs.branch_name }} | |
| RELEASE_TICKET_ID: ${{ github.event.inputs.release_ticket_id }} | |
| run: | | |
| # Create directory for artifact | |
| mkdir -p release-info | |
| # Create JSON file with release information using jq for safe construction | |
| jq -n \ | |
| --arg channel_id "$CHANNEL_ID" \ | |
| --arg thread_ts "$THREAD_TS" \ | |
| --arg pr_url "$PR_URL" \ | |
| --arg release_version "$NEW_VERSION_VALUE" \ | |
| --arg release_branch "$RELEASE_BRANCH" \ | |
| --arg release_ticket_id "$RELEASE_TICKET_ID" \ | |
| --arg created_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ | |
| '{ | |
| channel_id: $channel_id, | |
| thread_ts: $thread_ts, | |
| pr_url: $pr_url, | |
| release_version: $release_version, | |
| release_branch: $release_branch, | |
| release_ticket_id: $release_ticket_id, | |
| created_at: $created_at | |
| }' > release-info/release-info.json | |
| echo "Created release info artifact:" | |
| cat release-info/release-info.json | |
| - name: Upload release info artifact | |
| if: ${{ steps.slack-info.outputs.thread_ts != '' && steps.slack-info.outputs.channel_id != '' }} | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: release-info-v${{ env.NEW_VERSION_VALUE }}-${{ github.event.inputs.release_ticket_id }} | |
| path: release-info/ | |
| retention-days: 30 | |
| - name: Reply in Slack thread | |
| if: ${{ steps.slack-info.outputs.thread_ts != '' && steps.slack-info.outputs.channel_id != '' }} | |
| uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 | |
| continue-on-error: true | |
| with: | |
| method: chat.postMessage | |
| payload-templated: true | |
| retries: rapid | |
| token: ${{ secrets.SLACK_BOT_TOKEN }} | |
| payload: | | |
| { | |
| "channel": "${{ steps.slack-info.outputs.channel_id }}", | |
| "thread_ts": "${{ steps.slack-info.outputs.thread_ts }}", | |
| "text": ":rocket: Release PR created: <${{ steps.create-pr.outputs.pr_url }}|${{ steps.create-pr.outputs.pr_url }}>\nCC: <!subteam^S0555JBV36D> <!subteam^S03SW7DM8P3> <!subteam^S03SHJ20350>", | |
| "blocks": [ | |
| { | |
| "type": "section", | |
| "text": { | |
| "type": "mrkdwn", | |
| "text": ":rocket: *Release PR Created*: \n<${{ steps.create-pr.outputs.pr_url }}|${{ steps.create-pr.outputs.pr_url }}>\n\n*Monorepo Version:* v${{ env.NEW_VERSION_VALUE }}\n*Release Branch:* `${{ steps.create-release.outputs.branch_name }}`\n\n:point_right: Review and merge when ready!\nCC: <!subteam^S0555JBV36D> <!subteam^S03SW7DM8P3> <!subteam^S03SHJ20350>" | |
| } | |
| } | |
| ] | |
| } | |
| - name: Delete hotfix release base branch | |
| continue-on-error: true | |
| if: startsWith(github.ref_name, 'hotfix/') | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| env: | |
| BRANCH_TO_DELETE: ${{ github.ref_name }} | |
| with: | |
| github-token: ${{ steps.generate-token.outputs.token }} | |
| script: | | |
| const branchToDelete = process.env.BRANCH_TO_DELETE; | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const ref = `heads/${branchToDelete}`; | |
| try { | |
| await github.rest.git.deleteRef({ | |
| owner, | |
| repo, | |
| ref | |
| }); | |
| console.log(`Branch ${branchToDelete} deleted successfully.`); | |
| } catch (error) { | |
| console.error(`Error deleting branch ${branchToDelete}:`, error); | |
| process.exit(1); // Fail the workflow if branch deletion fails | |
| } |