# Copyright (c) 2020-2021, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. name: "Release" defaults: run: shell: bash -x -e -u -o pipefail {0} on: workflow_call: inputs: release-ref: required: true description: Ref (SHA or branch) to release type: string python-package: type: string description: Name of Python package python-version: type: string description: Python version to use for build required: false default: "3.10" library-name: type: string description: Name of Nemo library dry-run: type: boolean required: true description: Do not publish a wheel and GitHub release. version-bump-branch: type: string required: true description: Branch to target for version bump packaging: required: false description: "Packaging tool (supported: setuptools, hatch, uv)" type: string default: setuptools create-gh-release: required: false description: Create a GitHub release type: boolean default: true gh-release-tag-prefix: required: false description: This string will be preprended "as-is" to the version tag type: string default: "" gh-release-use-changelog-builder: required: false description: Use release-changelog-builder-action to dynamically build changelog type: boolean default: false gh-release-changelog-config: required: false description: Path to changelog builder configuration file type: string default: ".github/workflows/config/changelog-config.json" gh-release-from-tag: required: false description: Starting tag for changelog builder (leave empty for auto-detect) type: string default: "" gh-release-changelog-mode: required: false description: Mode for changelog builder type: string default: PR has-src-dir: required: false description: Whether the package has a src directory type: boolean default: false skip-test-wheel: required: false description: Skip the test wheel step type: boolean default: false custom-container: required: false description: Custom container to use for the build type: string default: "" runner: required: false description: Runner to use for the build type: string default: ubuntu-latest no-build-isolation: required: false description: Do not build the package in isolation type: boolean default: false app-id: required: true description: GitHub App ID type: string root-dir: required: false description: Navigate into a sub-directory type: string default: "./" submodules: required: false description: Whether to checkout submodules type: string default: '' publish-docs: required: false description: Publish documentation to S3 after release type: boolean default: true docs-target-path: required: false description: Target path within S3 bucket for documentation type: string default: "" docs-project-type: required: false description: Type of documentation project type: string default: single-docset secrets: TWINE_USERNAME: required: true TWINE_PASSWORD: required: true SLACK_WEBHOOK_ADMIN: required: true SLACK_WEBHOOK: required: true PAT: required: true SSH_KEY: required: false SSH_PWD: required: false BOT_KEY: required: true permissions: contents: write # To read repository content pull-requests: write # To create PR(s) jobs: build-test-publish-wheel-dry-run: name: Build test publish wheel (dry run) uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_build_test_publish_wheel.yml@v0.66.5 if: inputs.dry-run == false with: dry-run: true python-package: ${{ inputs.python-package }} python-version: ${{ inputs.python-version }} ref: ${{ inputs.release-ref }} packaging: ${{ inputs.packaging }} has-src-dir: ${{ inputs.has-src-dir }} skip-test-wheel: ${{ inputs.skip-test-wheel }} custom-container: ${{ inputs.custom-container }} runner: ${{ inputs.runner }} no-build-isolation: ${{ inputs.no-build-isolation }} root-dir: ${{ inputs.root-dir }} submodules: ${{ inputs.submodules }} secrets: TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }} TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} SLACK_WEBHOOK_ADMIN: ${{ secrets.SLACK_WEBHOOK_ADMIN }} SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} bump-next-version: runs-on: ubuntu-latest environment: main # ${{ inputs.dry-run == true && 'public' || 'main' }} needs: build-test-publish-wheel-dry-run if: | ( success() || !failure() ) && !cancelled() env: IS_DRY_RUN: ${{ inputs.dry-run }} PYPROJECT_NAME: ${{ inputs.python-package }} SRC_DIR: ${{ inputs.has-src-dir && 'src/' || '' }} steps: - uses: actions/create-github-app-token@v2 id: app-token with: app-id: ${{ inputs.app-id }} private-key: ${{ secrets.BOT_KEY }} - name: Checkout repository uses: actions/checkout@v6 with: path: ${{ github.run_id }} token: ${{ steps.app-token.outputs.token }} fetch-depth: 0 fetch-tags: true ref: ${{ inputs.release-ref }} - name: Install GPG run: sudo apt-get install -y gnupg2 - name: Import GPG key (for signing) uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec id: gpg-action with: gpg_private_key: ${{ secrets.SSH_KEY }} passphrase: ${{ secrets.SSH_PWD }} git_user_signingkey: true git_commit_gpgsign: true workdir: ${{ github.run_id }} - name: Bump version id: bump-version run: | cd ${{ github.run_id }} cd ${{ inputs.root-dir }} PACKAGE_INFO_FILE="$SRC_DIR${PYPROJECT_NAME//.//}/package_info.py" MAJOR=$(cat $PACKAGE_INFO_FILE | awk '/^MAJOR = /' | awk -F"= " '{print $2}') MINOR=$(cat $PACKAGE_INFO_FILE | awk '/^MINOR = /' | awk -F"= " '{print $2}') PATCH=$(cat $PACKAGE_INFO_FILE | awk '/^PATCH = /' | awk -F"= " '{print $2}') PRERELEASE=$(cat $PACKAGE_INFO_FILE | awk '/^PRE_RELEASE = /' | awk -F"= " '{print $2}' | tr -d '"' | tr -d "'") if [[ "$PRERELEASE" != "" ]]; then if [[ "$PRERELEASE" == *rc* ]]; then NEXT_PATCH=$PATCH NEXT_PRERELEASE=rc$((${PRERELEASE#rc} + 1)) elif [[ "$PRERELEASE" == *a* ]]; then NEXT_PATCH=$PATCH NEXT_PRERELEASE=a$((${PRERELEASE#a} + 1)) else echo "Unknown pre-release: $PRERELEASE" exit 1 fi else NEXT_PATCH=$((${PATCH} + 1)) NEXT_PRERELEASE=$PRERELEASE fi sed -i "/^PATCH/c\PATCH = $NEXT_PATCH" $PACKAGE_INFO_FILE sed -i "/^PRE_RELEASE/c\PRE_RELEASE = \"$NEXT_PRERELEASE\"" $PACKAGE_INFO_FILE echo "version=$MAJOR.$MINOR.$NEXT_PATCH$NEXT_PRERELEASE" | tee -a "$GITHUB_OUTPUT" - name: Create and push deployment branch env: PYPROJECT_NAME: ${{ inputs.python-package }} GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | cd ${{ github.run_id }} cd ${{ inputs.root-dir }} PACKAGE_INFO_FILE="$SRC_DIR${PYPROJECT_NAME//.//}/package_info.py" TMP_BRANCH="deploy-release/$(uuidgen)" git checkout -b "$TMP_BRANCH" git add $PACKAGE_INFO_FILE git commit -sS -m "beep boop 🤖: Bumping ${PYPROJECT_NAME} to v${{ steps.bump-version.outputs.version }}" || echo "No changes to commit" git push -u origin "$TMP_BRANCH" echo "TMP_BRANCH=$TMP_BRANCH" | tee -a $GITHUB_ENV # Create PR to collect app based status checks that run on PRs only # (like DCO check) gh pr create \ --base ${{ inputs.version-bump-branch }} \ --head $TMP_BRANCH \ --title "beep boop 🤖: Bumping ${PYPROJECT_NAME} to v${{ steps.bump-version.outputs.version }}" \ --body "This is an automated PR to bump ${{ inputs.library-name }}:${PYPROJECT_NAME} to v${{ steps.bump-version.outputs.version }}." - name: Wait for status checks on tmp branch uses: actions/github-script@v7 id: wait-status with: github-token: ${{ steps.app-token.outputs.token }} script: | const branch = process.env.TMP_BRANCH; const owner = context.repo.owner; const repo = context.repo.repo; // Get latest commit SHA of branch const { data: refData } = await github.rest.git.getRef({ owner, repo, ref: `heads/${branch}`, // note: no 'refs/' prefix here }); const sha = refData.object.sha; console.log(`Polling status for commit SHA: ${sha}`); let checksPassed = false; let maxAttempts = 30; let attempt = 0; const delay = ms => new Promise(res => setTimeout(res, ms)); while (!checksPassed && attempt < maxAttempts) { attempt++; // Use commit SHA instead of branch ref const { data: status } = await github.rest.repos.getCombinedStatusForRef({ owner, repo, ref: sha, }); const { data: checks } = await github.rest.checks.listForRef({ owner, repo, ref: sha, }); const allStatuses = status.statuses; const allChecks = checks.check_runs; if (allStatuses.length === 0 && allChecks.length === 0) { console.log(`Attempt ${attempt}: No checks or statuses yet. Waiting...`); await delay(10000); continue; } const statusesOk = allStatuses.every(s => s.state === 'success'); const checksOk = allChecks.every(c => c.status === 'completed'); if (statusesOk && checksOk) { console.log('✅ All checks passed.'); checksPassed = true; break } console.log(`Attempt ${attempt}: Checks not complete yet. Waiting...`); await delay(10000); } if (!checksPassed) { core.setFailed('❌ Status checks did not pass in time'); } - name: Merge into ${{ inputs.version-bump-branch }} run: | cd ${{ github.run_id }} git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" CMD=$(echo -E 'git push origin ${{ inputs.version-bump-branch }}') if [[ "$IS_DRY_RUN" == "true" ]]; then echo "dry-run enabled, would have run: $CMD" else # Here we account for potential race conditions from multiple concurrent releases. # Those can be legit (operating on different packages within the monorepo, for example) # but the pushes would be still rejected purely because of git's inability to # push non-fast-forward updates to the branch. In this case we would need to let # a retry. git fetch origin ${{ inputs.version-bump-branch }} git checkout ${{ inputs.version-bump-branch }} git merge ${{ env.TMP_BRANCH }} for attempt in {1..3}; do if eval "$CMD"; then echo "Git push succeeded on attempt $attempt" break else echo "Git push failed on attempt $attempt" if [[ $attempt -lt 3 ]]; then sleep $((RANDOM % 3 + 1)) # We refetch, reset and re-merge. Note resetting because the local # branch is "contaminated" with previous merge attempt. git fetch origin ${{ inputs.version-bump-branch }} git reset --hard origin/${{ inputs.version-bump-branch }} git merge ${{ env.TMP_BRANCH }} else echo "Git push failed after 3 attempts" exit 1 fi fi done fi - name: Delete ${{ env.TMP_BRANCH }} branch if: always() run: | cd ${{ github.run_id }} git push -d origin ${{ env.TMP_BRANCH }} build-test-publish-wheel: uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_build_test_publish_wheel.yml@v0.66.5 needs: bump-next-version if: | ( success() || !failure() ) && !cancelled() with: dry-run: ${{ inputs.dry-run }} python-package: ${{ inputs.python-package }} python-version: ${{ inputs.python-version }} ref: ${{ inputs.release-ref }} packaging: ${{ inputs.packaging }} has-src-dir: ${{ inputs.has-src-dir }} skip-test-wheel: ${{ inputs.skip-test-wheel }} custom-container: ${{ inputs.custom-container }} runner: ${{ inputs.runner }} no-build-isolation: ${{ inputs.no-build-isolation }} root-dir: ${{ inputs.root-dir }} secrets: TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }} TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} SLACK_WEBHOOK_ADMIN: ${{ secrets.SLACK_WEBHOOK_ADMIN }} SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} create-gh-release: needs: [build-test-publish-wheel] runs-on: ubuntu-latest environment: ${{ inputs.dry-run == true && 'public' || 'main' }} if: | ( success() || !failure() ) && inputs.create-gh-release == true && !cancelled() outputs: is-release-candidate: ${{ steps.version-number.outputs.is-release-candidate }} env: REPOSITORY: ${{ github.repository }} PROJECT_NAME: ${{ inputs.library-name }} VERSION: ${{ needs.build-test-publish-wheel.outputs.version }} TAG_PREFIX: ${{ inputs.gh-release-tag-prefix || '' }} steps: - name: Checkout repository uses: actions/checkout@v6 with: path: ${{ github.run_id }} ref: ${{ inputs.release-ref }} token: ${{ secrets.PAT || secrets.GITHUB_TOKEN }} fetch-depth: 0 fetch-tags: true - name: Determine fromTag for changelog id: determine-from-tag if: inputs.gh-release-use-changelog-builder == true run: | cd ${{ github.run_id }} # If gh-release-from-tag is provided, use it if [[ -n "${{ inputs.gh-release-from-tag }}" ]]; then FROM_TAG="${{ inputs.gh-release-from-tag }}" echo "Using provided fromTag: $FROM_TAG" else # Get the most recent tag FROM_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") if [[ -z "$FROM_TAG" ]]; then echo "No previous tags found, leaving fromTag empty" else echo "Auto-detected most recent tag: $FROM_TAG" fi fi echo "from-tag=$FROM_TAG" >> $GITHUB_OUTPUT - name: Build Changelog id: build-changelog if: inputs.gh-release-use-changelog-builder == true uses: mikepenz/release-changelog-builder-action@v6.1.0 env: GITHUB_TOKEN: ${{ secrets.PAT || secrets.GITHUB_TOKEN }} with: configuration: ${{ github.run_id }}/${{ inputs.gh-release-changelog-config }} owner: ${{ github.repository_owner }} repo: ${{ github.event.repository.name }} ignorePreReleases: "false" failOnError: "false" fromTag: ${{ steps.determine-from-tag.outputs.from-tag }} toTag: ${{ inputs.release-ref }} mode: ${{ inputs.gh-release-changelog-mode }} - name: Create release id: version-number env: SHA: ${{ inputs.release-ref }} GH_TOKEN: ${{ secrets.PAT }} IS_DRY_RUN: ${{ inputs.dry-run }} BUILT_CHANGELOG: ${{ steps.build-changelog.outputs.changelog }} run: | cd ${{ github.run_id }} cd ${{ inputs.root-dir }} IS_RELEASE_CANDIDATE=$([[ "$VERSION" == *rc* ]] && echo "true" || echo "false") IS_ALPHA=$([[ "$VERSION" == *a* ]] && echo "true" || echo "false") IS_PRERELEASE=$([[ "$IS_RELEASE_CANDIDATE" == "true" || "$IS_ALPHA" == "true" ]] && echo "true" || echo "false") NAME="NVIDIA $PROJECT_NAME ${VERSION}" # Use built changelog if available, otherwise fall back to CHANGELOG.md if [[ -n "$BUILT_CHANGELOG" ]]; then CHANGELOG="$BUILT_CHANGELOG" elif [[ "$IS_RELEASE_CANDIDATE" == "true" ]]; then DATE=$(date +"%Y-%m-%d") CHANGELOG="Prerelease: $NAME ($DATE)" else CHANGELOG=$(awk '/^## '"$NAME"'/{flag=1; next} /^## /{flag=0} flag' CHANGELOG.md) CHANGELOG=$(echo "$CHANGELOG" | sed '/./,$!d' | sed ':a;N;$!ba;s/\n$//') fi echo "is-release-candidate=$IS_RELEASE_CANDIDATE" | tee -a "$GITHUB_OUTPUT" PAYLOAD=$(jq -nc \ --arg TAG_NAME "${TAG_PREFIX}v${VERSION}" \ --arg CI_COMMIT_BRANCH "$SHA" \ --arg NAME "$NAME" \ --arg BODY "$CHANGELOG" \ --argjson PRERELEASE "$IS_PRERELEASE" \ '{ "tag_name": $TAG_NAME, "target_commitish": $CI_COMMIT_BRANCH, "name": $NAME, "body": $BODY, "draft": false, "prerelease": $PRERELEASE, }' ) echo -E "$PAYLOAD" > payload.txt CMD=$(echo -E 'curl -L \ -X POST \ -H "Accept: application/vnd.github+json" \ -H "Authorization: Bearer '"$GH_TOKEN"'" \ -H "X-GitHub-Api-Version: 2022-11-28" \ https://api.github.com/repos/'"$REPOSITORY"'/releases \ -d @payload.txt ') if [[ "$IS_DRY_RUN" == "true" ]]; then echo -E "$CMD" else eval "$CMD" fi notify: needs: [build-test-publish-wheel, create-gh-release] runs-on: ubuntu-latest environment: ${{ inputs.dry-run == true && 'public' || 'main' }} env: GH_URL: https://github.com/${{ github.repository }}/releases/tag/${{ inputs.gh-release-tag-prefix || '' }}v${{ needs.build-test-publish-wheel.outputs.version }} PYPI_URL: https://${{ inputs.dry-run == true && 'test.' || '' }}pypi.org/project/${{ needs.build-test-publish-wheel.outputs.pypi-name }}/${{ needs.build-test-publish-wheel.outputs.version }}/ PROJECT_NAME: ${{ inputs.library-name }} VERSION: ${{ needs.build-test-publish-wheel.outputs.version }} steps: - name: Checkout uses: actions/checkout@v6 with: repository: NVIDIA-NeMo/FW-CI-templates ref: v0.17.0 path: send-slack-alert - name: Send Slack alert uses: ./send-slack-alert/.github/actions/send-slack-alert env: MESSAGE: | ${{ inputs.dry-run == true && 'This is a dry-run, nothing actually happened: ' || '' }}We have released `${{ env.VERSION }}` of `NVIDIA ${{ env.PROJECT_NAME }}` 🚀✨🎉 • <${{ env.GH_URL }}|GitHub release> • <${{ env.PYPI_URL }}|PyPi release> with: message: ${{ env.MESSAGE }} webhook: ${{ secrets.SLACK_WEBHOOK }} build-docs: needs: [build-test-publish-wheel, create-gh-release] uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_build_docs.yml@v0.72.0 if: | ( success() || !failure() ) && inputs.publish-docs == true && !cancelled() with: ref: ${{ inputs.gh-release-tag-prefix || '' }}v${{ needs.build-test-publish-wheel.outputs.version }} publish-docs: needs: [build-test-publish-wheel, create-gh-release, build-docs] runs-on: ubuntu-latest environment: ${{ inputs.dry-run == true && 'public' || 'main' }} if: | ( success() || !failure() ) && inputs.publish-docs == true && !cancelled() env: VERSION: ${{ needs.build-test-publish-wheel.outputs.version }} LIBRARY_NAME: ${{ inputs.library-name }} S3_TARGET_PATH: ${{ inputs.docs-target-path }} steps: - name: Checkout FW-CI-templates uses: actions/checkout@v6 with: repository: NVIDIA-NeMo/FW-CI-templates ref: v0.72.0 path: FW-CI-templates - name: Generate request name id: request-name shell: bash run: | REQUEST_NAME="${S3_TARGET_PATH//\//-}-publish-docs-${{ github.run_id }}" echo "value=${REQUEST_NAME}" >> $GITHUB_OUTPUT - name: Publish documentation uses: ./FW-CI-templates/.github/actions/publish-docs with: dry-run: ${{ inputs.dry-run }} artifacts-name: docs-html artifacts-path: _build/html emails-csv: ${{ inputs.notify-emails && format('{0},{1}', vars.docs_release_emails, inputs.notify-emails) || vars.docs_release_emails }} overwrite-latest-on-tag: true docs-version-override: ${{ env.VERSION }} project-type: ${{ inputs.docs-project-type }} request-name: ${{ steps.request-name.outputs.value }} aws-region: ${{ vars.DOCS_AWS_REGION }} aws-role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }} aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} akamai-host: ${{ secrets.AKAMAI_HOST }} akamai-client-token: ${{ secrets.AKAMAI_CLIENT_TOKEN }} akamai-client-secret: ${{ secrets.AKAMAI_CLIENT_SECRET }} akamai-access-token: ${{ secrets.AKAMAI_ACCESS_TOKEN }} s3-target-root: ${{ secrets.S3_BUCKET_NAME }} s3-target-path: ${{ inputs.docs-target-path }}