Skip to content

BitGoJS Release

BitGoJS Release #74

Workflow file for this run

---
name: BitGoJS Release
on:
workflow_dispatch:
inputs:
dry-run:
description: |
If true, only runs checks without performing the actual release
type: boolean
required: false
default: false
recovery-mode:
description: |
Recover from a partial-publish failure. Skips version bumping,
master→rel/latest merge, and GPG signing. Runs `lerna publish
from-package` against rel/latest, publishing only versions
missing from npm. Release notes, GitHub release, and Express
Docker publish still run.
IMPORTANT: from-package publishes whatever versions are in the
rel/latest package.json files at trigger time. Verify rel/latest
HEAD matches the failed release before triggering — the workflow
logs the resolved SHA and the planned publish list.
type: boolean
required: false
default: false
permissions:
contents: write
id-token: write
pull-requests: read
# Prevent overlapping releases. workflow_dispatch runs are serialized;
# a normal release and a recovery run cannot race against rel/latest.
concurrency:
group: npmjs-release
cancel-in-progress: false
env:
NX_NO_CLOUD: true
NX_SKIP_NX_CACHE: true
DOCKER_HUB_USERNAME: "bgdeploybot"
jobs:
verify-back-merge:
name: Verify previous back-merge landed
# Each release bumps versions on rel/latest; a back-merge PR (rel/latest ->
# master) returns them. If that PR is missed, rel/latest stays ahead and the
# next release would publish on top of an out-of-sync rel/latest. Block the
# release until the previous back-merge has landed.
# Skipped in recovery mode, where rel/latest is intentionally ahead.
if: ${{ !inputs.recovery-mode }}
runs-on: ${{ vars.BASE_RUNNER_TYPE || 'ubuntu-latest' }}
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: master
fetch-depth: 0
- name: Ensure rel/latest is not ahead of master
run: |
git fetch --no-tags origin +refs/heads/rel/latest:refs/remotes/origin/rel/latest
if git merge-base --is-ancestor origin/rel/latest origin/master; then
echo "✅ rel/latest is not ahead of master — previous back-merge has landed."
exit 0
fi
echo "::error::rel/latest is ahead of master. The back-merge PR (rel/latest -> master) from the previous release has not landed. Merge it before releasing."
echo "Commits on rel/latest not yet in master:"
git log --oneline origin/master..origin/rel/latest
exit 1
get-release-context:
name: Get release context
if: ${{ !inputs.recovery-mode }}
runs-on: ${{ vars.BASE_RUNNER_TYPE || 'ubuntu-latest' }}
timeout-minutes: 10
outputs:
last-release-tag: ${{ steps.get-release-info.outputs.last-release-tag }}
last-release-sha: ${{ steps.get-release-info.outputs.last-release-sha }}
current-master-sha: ${{ steps.get-release-info.outputs.current-master-sha }}
commits-since-release: ${{ steps.get-release-info.outputs.commits-since-release }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: master
fetch-depth: 0
fetch-tags: true
- name: Get release information
id: get-release-info
run: |
# Get the latest stable release tag
LAST_RELEASE_TAG=$(git tag --sort=-version:refname | grep -E 'bitgo@[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)
# Get the commit SHA for that release
LAST_RELEASE_SHA=$(git rev-parse "$LAST_RELEASE_TAG^{}")
# Get the current master HEAD commit
CURRENT_MASTER_SHA=$(git rev-parse HEAD)
# Count commits since last release
COMMITS_SINCE_RELEASE=$(git log --oneline "${LAST_RELEASE_TAG}..HEAD" | wc -l)
# Verify we have commits to process
if [ "$COMMITS_SINCE_RELEASE" -eq 0 ]; then
echo "::error::No commits found since last release $LAST_RELEASE_TAG. Nothing to process."
exit 1
fi
# Output the information
echo "Last release tag: $LAST_RELEASE_TAG"
echo "Last release SHA: $LAST_RELEASE_SHA"
echo "Current master SHA: $CURRENT_MASTER_SHA"
echo "Commits since release: $COMMITS_SINCE_RELEASE"
# Set outputs
{
echo "last-release-tag=$LAST_RELEASE_TAG"
echo "last-release-sha=$LAST_RELEASE_SHA"
echo "current-master-sha=$CURRENT_MASTER_SHA"
echo "commits-since-release=$COMMITS_SINCE_RELEASE"
} >> "$GITHUB_OUTPUT"
echo "Commits to process:"
git log --oneline "${LAST_RELEASE_TAG}..HEAD"
- name: Generate release commit summary
run: |
{
echo "## Commits to be Released"
echo ""
echo "From ${{ steps.get-release-info.outputs.last-release-sha }} to ${{ steps.get-release-info.outputs.current-master-sha }}"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
# Get commits excluding merge commits
git log --oneline --no-merges "${{ steps.get-release-info.outputs.last-release-sha }}..${{ steps.get-release-info.outputs.current-master-sha }}" | while read -r line; do
commit_hash=$(echo "$line" | cut -d' ' -f1)
commit_msg=$(echo "$line" | cut -d' ' -f2-)
# Get full commit message to check for TICKET: pattern
full_commit_msg=$(git log -1 --pretty=format:"%B" "$commit_hash")
# Extract Jira ticket from commit message (handles multiple patterns, case insensitive)
# 1. Direct pattern in subject: VL-1234, CORE-567, etc.
# 2. TICKET: VL-1234 pattern in body
jira_ticket=$(echo "$full_commit_msg" | grep -oiE '(ticket:\s*)?[A-Z]+-[0-9]+' | sed 's/[Tt][Ii][Cc][Kk][Ee][Tt]:\s*//' | head -1)
if [[ -n "$jira_ticket" ]]; then
jira_link="[$jira_ticket](https://bitgoinc.atlassian.net/browse/${jira_ticket})"
echo "- \`$commit_hash\` $commit_msg - $jira_link" >> "$GITHUB_STEP_SUMMARY"
else
echo "- \`$commit_hash\` $commit_msg" >> "$GITHUB_STEP_SUMMARY"
fi
done
echo "" >> "$GITHUB_STEP_SUMMARY"
get-recovery-context:
name: Get recovery context
if: inputs.recovery-mode
runs-on: ${{ vars.BASE_RUNNER_TYPE || 'ubuntu-latest' }}
timeout-minutes: 10
outputs:
# Pinned SHA. release-bitgojs checks out this exact commit, not the
# rel/latest branch tip, so the publish cannot drift from what the
# env reviewer approved.
sha: ${{ steps.resolve.outputs.sha }}
steps:
- name: Checkout rel/latest
uses: actions/checkout@v6
with:
ref: rel/latest
fetch-depth: 1
- name: Resolve SHA and show recovery target
id: resolve
run: |
# Pin the SHA at preview time and surface it (plus the planned
# publish list) BEFORE the env-gated publish job runs, so
# reviewers approving the `npmjs-release` environment can
# sanity-check what will be published.
sha="$(git rev-parse HEAD)"
if [ -z "$sha" ]; then
echo "::error::Failed to resolve rel/latest SHA. Refusing to proceed."
exit 1
fi
echo "sha=$sha" >> "$GITHUB_OUTPUT"
{
echo "## Recovery target"
echo ""
echo "Branch: \`rel/latest\`"
echo "Resolved SHA: \`$sha\`"
echo "Subject: $(git log -1 --pretty=format:'%s')"
echo ""
echo "### Versions in rel/latest package.jsons"
echo ""
echo '```'
for f in modules/*/package.json; do
jq -r '"\(.name)@\(.version)\(if .private then " (private)" else "" end)"' "$f"
done | sort
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
release-bitgojs:
name: Release BitGoJS
needs:
- get-release-context
- get-recovery-context
- verify-back-merge
if: ${{ always() && needs.get-release-context.result != 'failure' && needs.get-recovery-context.result != 'failure' && needs.verify-back-merge.result != 'failure' }}
runs-on: ${{ vars.BASE_RUNNER_TYPE || 'ubuntu-latest' }}
timeout-minutes: 60
environment: npmjs-release
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
# Recovery mode pins to the SHA resolved by get-recovery-context so
# the publish cannot drift from the commit the env reviewer approved
# (rel/latest could otherwise advance during the approval wait).
# Normal mode uses the master SHA captured by get-release-context.
ref: ${{ inputs.recovery-mode && needs.get-recovery-context.outputs.sha || needs.get-release-context.outputs.current-master-sha }}
token: ${{ secrets.BITGOBOT_PAT_TOKEN || github.token }}
fetch-depth: 0
# version-bump-summary uses `git tag --points-at HEAD`. In recovery
# mode the bump tags were created by a prior failed run and live
# only on origin, so we must fetch them.
fetch-tags: true
- name: Configure GPG
if: ${{ inputs.dry-run == false && !inputs.recovery-mode }}
run: |
echo "${{ secrets.BITGOBOT_GPG_PRIVATE_KEY }}" | gpg --batch --import
git config --global user.signingkey 477C1077386517D7ED64277C56FC3A6E8059B391
git config --global commit.gpgsign true
git config --global user.email "bitgobot@bitgo.com"
git config --global user.name "bitgobot"
- name: Configure npmrc
run: |
echo "engine-strict=true" > ~/.npmrc
- name: Setup Node.js with nvm
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
- name: Switch to rel/latest branch
if: ${{ !inputs.recovery-mode }}
run: |
git checkout rel/latest
git pull origin rel/latest
- name: Merge master into rel/latest
if: ${{ !inputs.recovery-mode }}
run: |
echo "Merging master commit ${{ needs.get-release-context.outputs.current-master-sha }} into rel/latest"
git merge ${{ needs.get-release-context.outputs.current-master-sha }} --no-edit
- name: Install dependencies
run: |
yarn install --frozen-lockfile
# osv-scanner has no severity filter and exits non-zero on ANY finding,
# so run it with continue-on-error and let the severity gate below
# decide pass/fail based on CVSS score (fails on HIGH/CRITICAL only).
- name: Audit Dependencies
id: osv-scan
continue-on-error: true
uses: google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
with:
scan-args: |-
--config=osv-scanner.toml
--format=json
--output-file=osv-results.json
./
- name: Enforce Vulnerability Severity Threshold
uses: ./.github/actions/osv-severity-gate
with:
scan-outcome: ${{ steps.osv-scan.outcome }}
- name: Run dependency check
run: |
yarn check-deps
# Trusted publishing (OIDC) cannot publish a package that doesn't already
# exist on npm. Catch missing packages before the publish step so the
# release doesn't partially fail midway through.
- name: Verify all packages exist on npm
uses: ./.github/actions/verify-npm-packages
- name: Publish new version
if: ${{ inputs.dry-run == false && !inputs.recovery-mode }}
run: |
yarn lerna publish --sign-git-tag --sign-git-commit --include-merged-tags --conventional-commits --conventional-graduate --yes
env:
NPM_CONFIG_PROVENANCE: true
# Tells bitgo's `prepack` script (scripts/generate-bitgo-shrinkwrap.ts) to
# actually generate npm-shrinkwrap.json — kept opt-in so a plain local
# `npm pack` doesn't force a network install of the full dependency tree.
BITGO_GENERATE_SHRINKWRAP: true
- name: Publish missing versions (recovery)
if: ${{ inputs.dry-run == false && inputs.recovery-mode }}
run: |
# `from-package` reads each package.json's `version`, queries npm,
# and publishes only versions missing from the registry. No bump,
# no tag, no git push.
yarn lerna publish from-package --yes
env:
NPM_CONFIG_PROVENANCE: true
BITGO_GENERATE_SHRINKWRAP: true
- name: Verify recovery published the missing versions
if: ${{ inputs.dry-run == false && inputs.recovery-mode }}
run: |
# Walk every non-private package and confirm the version on
# rel/latest is now reachable on the npm registry. Catches the
# case where lerna reports success but a package didn't land
# (e.g., another transient registry error).
missing=()
for f in modules/*/package.json; do
if [ "$(jq -r '.private // false' "$f")" = "true" ]; then continue; fi
name=$(jq -r '.name' "$f")
version=$(jq -r '.version' "$f")
code=$(curl -sL -o /dev/null -w "%{http_code}" -- "https://registry.npmjs.org/${name}/${version}")
if [ "$code" != "200" ]; then
missing+=("${name}@${version} (HTTP ${code})")
fi
done
if [ "${#missing[@]}" -ne 0 ]; then
echo "::error::Recovery left versions still missing from npm:"
printf ' - %s\n' "${missing[@]}"
exit 1
fi
echo "✅ All public package versions on rel/latest are present on npm."
# bitgo's `prepack` script generates npm-shrinkwrap.json at pack time (see
# scripts/generate-bitgo-shrinkwrap.ts). npm only honors a shipped shrinkwrap
# for installers when the registry's version metadata says _hasShrinkwrap: true —
# confirm that here so a silent registry-side omission fails the release
# instead of shipping a package whose shrinkwrap consumers won't get.
- name: Verify bitgo package has shrinkwrap metadata
if: ${{ inputs.dry-run == false }}
run: |
version=$(jq -r '.version' modules/bitgo/package.json)
# Registry metadata can lag briefly right after publish, so retry before
# failing the release on what might just be propagation delay.
for attempt in 1 2 3 4 5; do
has_shrinkwrap=$(curl -sL "https://registry.npmjs.org/bitgo/${version}" | jq -r '._hasShrinkwrap // false')
if [ "$has_shrinkwrap" = 'true' ]; then
echo "✅ bitgo@${version} published with _hasShrinkwrap: true."
exit 0
fi
[ "$attempt" -lt 5 ] && sleep 5
done
echo "::error::Published bitgo@${version} is missing _hasShrinkwrap metadata on the npm registry."
exit 1
- name: Generate version bump summary
id: version-bump-summary
if: inputs.dry-run == false
continue-on-error: true
uses: ./.github/actions/version-bump-summary
- name: Extract published version
if: inputs.dry-run == false
id: extract-version
run: |
NEW_VERSION=$(jq -r '.version' ./modules/bitgo/package.json)
echo "New version: $NEW_VERSION"
echo "new-version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
# NPM publish has already happened by this point, so a failure here
# requires manual remediation (see VL-5474). Retry transient failures,
# then surface as a job failure — no continue-on-error. Existing
# org-level Slack notifications fire on the resulting job failure.
- name: Create GitHub release
if: inputs.dry-run == false && steps.version-bump-summary.outcome == 'success'
id: create-github-release
env:
GH_TOKEN: ${{ secrets.BITGOBOT_PAT_TOKEN || github.token }}
run: |
for attempt in 1 2 3; do
if gh release create "v${{ steps.extract-version.outputs.new-version }}" \
--latest \
--title "v${{ steps.extract-version.outputs.new-version }}" \
--notes-file "${{ steps.version-bump-summary.outputs.text-file }}"; then
echo "GitHub release created on attempt $attempt"
exit 0
fi
if [ "$attempt" -lt 3 ]; then
delay=$((attempt * 30))
echo "Attempt $attempt failed. Retrying in ${delay}s..."
sleep "$delay"
fi
done
echo "::error::All retry attempts exhausted creating GitHub release v${{ steps.extract-version.outputs.new-version }}. NPM publish succeeded; manual GitHub release required."
exit 1
get-express-release-context:
name: Get Express release context
if: ${{ always() && inputs.dry-run == false && needs.release-bitgojs.result == 'success' }}
needs:
- release-bitgojs
runs-on: ${{ vars.BASE_RUNNER_TYPE || 'ubuntu-latest' }}
timeout-minutes: 10
outputs:
version: ${{ steps.compute-express-git-tag.outputs.version }}
git-tag: ${{ steps.compute-express-git-tag.outputs.git-tag }}
git-sha: ${{ steps.compute-express-git-sha.outputs.git-sha }}
docker-exists: ${{ steps.check-docker-image.outputs.docker-exists }}
steps:
- name: Checkout rel/latest branch
uses: actions/checkout@v6
with:
ref: rel/latest
fetch-depth: 0
fetch-tags: true
- name: Compute express target version and tag
id: compute-express-git-tag
run: |
VERSION=$(jq -r '.version' ./modules/express/package.json)
TAG="@bitgo/express@$VERSION"
echo "Current latest express version: $VERSION"
echo "Expected latest express git tag: $TAG"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "git-tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Checkout express target git tag
uses: actions/checkout@v6
with:
ref: ${{ steps.compute-express-git-tag.outputs.git-tag }}
fetch-depth: 2
- name: Parse express git SHA
id: compute-express-git-sha
run: |
GIT_SHA=$(git rev-parse HEAD)
echo "Git SHA: $GIT_SHA"
echo "git-sha=$GIT_SHA" >> "$GITHUB_OUTPUT"
- name: Sanity Check Express Git Tag
run: |
CURRENT_VERSION="${{ steps.compute-express-git-tag.outputs.version }}"
PREVIOUS_VERSION=$(git show HEAD~1:./modules/express/package.json | jq -r '.version')
echo "Current version: $CURRENT_VERSION"
echo "Previous version: $PREVIOUS_VERSION"
if [ "$CURRENT_VERSION" == "$PREVIOUS_VERSION" ]; then
echo "::error::Express version bump does not line up with git tag location."
echo "::error::This suggests the git tag may have been moved."
exit 1
fi
echo "✅ Express version bump lines up with git tag"
- name: Check if Docker image already exists in Docker Hub
id: check-docker-image
run: |
VERSION="${{ steps.compute-express-git-tag.outputs.version }}"
if curl -s -f "https://hub.docker.com/v2/repositories/bitgo/express/tags/$VERSION" > /dev/null; then
echo "⚠️ Docker image bitgo/express:$VERSION already exists — skipping publish"
echo "docker-exists=true" >> "$GITHUB_OUTPUT"
else
echo "✅ Docker image bitgo/express:$VERSION does not exist — will publish"
echo "docker-exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Update Express GitHub summary
run: |
if [ "${{ steps.check-docker-image.outputs.docker-exists }}" == "true" ]; then
{
echo "### ⚠️ Docker publish skipped"
echo "Image \`bitgo/express:${{ steps.compute-express-git-tag.outputs.version }}\` already exists in Docker Hub."
} >> "$GITHUB_STEP_SUMMARY"
else
{
echo "## BitGo Express Release Information"
echo ""
echo "Express Version: ${{ steps.compute-express-git-tag.outputs.version }}"
echo "Git Tag: ${{ steps.compute-express-git-tag.outputs.git-tag }}"
echo "Commit SHA: ${{ steps.compute-express-git-sha.outputs.git-sha }}"
echo ""
echo "### Docker Images to be deployed:"
echo "- \`bitgo/express:latest\`"
echo "- \`bitgo/express:${{ steps.compute-express-git-tag.outputs.version }}\`"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
fi
publish-express-to-docker-hub:
name: Publish Express To Docker Hub
if: ${{ always() && needs.get-express-release-context.result == 'success' && needs.get-express-release-context.outputs.docker-exists != 'true' }}
needs:
- get-express-release-context
runs-on: ${{ vars.BASE_RUNNER_TYPE || 'ubuntu-latest' }}
timeout-minutes: 40
environment: bitgo-express
steps:
- name: Checkout BitGoJS repository
uses: actions/checkout@v6
with:
ref: ${{ needs.get-express-release-context.outputs.git-sha }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Log in to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ env.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API_KEY }}
- name: Generate build date
id: build-date
run: |
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
echo "build-date=$BUILD_DATE" >> "$GITHUB_OUTPUT"
- name: Build and push Express Docker image
id: docker-build
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile
push: true
tags: |
bitgo/express:latest
bitgo/express:${{ needs.get-express-release-context.outputs.version }}
build-args: |
VERSION=${{ needs.get-express-release-context.outputs.version }}
BUILD_DATE=${{ steps.build-date.outputs.build-date }}
GIT_HASH=${{ needs.get-express-release-context.outputs.git-sha }}