diff --git a/.github/scripts/bump-major-version.sh b/.github/scripts/bump-major-version.sh new file mode 100755 index 0000000..53c25be --- /dev/null +++ b/.github/scripts/bump-major-version.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="${1:?Usage: bump-major-version.sh [version_file]}" +VERSION_FILE="${2:-.github/workflows/version.txt}" +MAJOR="${VERSION%%.*}" + +echo "$MAJOR" > "$VERSION_FILE" diff --git a/.github/scripts/bump-major-version.test.sh b/.github/scripts/bump-major-version.test.sh new file mode 100755 index 0000000..70e0dbe --- /dev/null +++ b/.github/scripts/bump-major-version.test.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUMP_MAJOR_SH="$SCRIPT_DIR/bump-major-version.sh" +FAILURES=0 + +assert_eq() { + local description="$1" expected="$2" actual="$3" + if [ "$expected" != "$actual" ]; then + echo "FAIL: $description (expected '$expected', got '$actual')" >&2 + FAILURES=$((FAILURES + 1)) + else + echo "PASS: $description" + fi +} + +# Test: writes the major version number to the given file +tmp_file=$(mktemp) +bash "$BUMP_MAJOR_SH" "4.0.0" "$tmp_file" +assert_eq "writes major version" "4" "$(cat "$tmp_file")" +rm -f "$tmp_file" + +# Test: multi-digit major version +tmp_file=$(mktemp) +bash "$BUMP_MAJOR_SH" "12.3.4" "$tmp_file" +assert_eq "writes multi-digit major version" "12" "$(cat "$tmp_file")" +rm -f "$tmp_file" + +# Test: missing version argument fails +if bash "$BUMP_MAJOR_SH" >/dev/null 2>&1; then + echo "FAIL: expected failure with no version argument" >&2 + FAILURES=$((FAILURES + 1)) +else + echo "PASS: fails with no version argument" +fi + +if [ "$FAILURES" -gt 0 ]; then + echo "$FAILURES test(s) failed" >&2 + exit 1 +fi + +echo "All bump-major-version.sh tests passed" diff --git a/.github/scripts/next-version.sh b/.github/scripts/next-version.sh new file mode 100755 index 0000000..a711ce0 --- /dev/null +++ b/.github/scripts/next-version.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +BUMP="${1:?Usage: next-version.sh }" + +case "$BUMP" in + patch|minor|major) ;; + *) + echo "Unknown bump type: $BUMP" >&2 + exit 1 + ;; +esac + +LATEST_SEMVER=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \ + | sed 's/^v//' \ + | sort -t. -k1,1n -k2,2n -k3,3n \ + | tail -1 || true) + +if [ -n "$LATEST_SEMVER" ]; then + MAJOR=$(echo "$LATEST_SEMVER" | cut -d. -f1) + MINOR=$(echo "$LATEST_SEMVER" | cut -d. -f2) + PATCH=$(echo "$LATEST_SEMVER" | cut -d. -f3) + + case "$BUMP" in + major) + MAJOR=$((MAJOR + 1)) + MINOR=0 + PATCH=0 + ;; + minor) + MINOR=$((MINOR + 1)) + PATCH=0 + ;; + patch) + PATCH=$((PATCH + 1)) + ;; + esac +else + LATEST_FLOATING=$(git tag -l 'v[0-9]*' \ + | grep -E '^v[0-9]+$' \ + | sed 's/^v//' \ + | sort -n \ + | tail -1 || true) + + if [ -z "$LATEST_FLOATING" ]; then + echo "No version tags found (neither vX.Y.Z nor vN)." >&2 + exit 1 + fi + + echo "No vX.Y.Z tag found; bootstrapping from floating tag v${LATEST_FLOATING}. Ignoring requested bump '${BUMP}'." >&2 + MAJOR="$LATEST_FLOATING" + MINOR=0 + PATCH=0 +fi + +NEXT_VERSION="${MAJOR}.${MINOR}.${PATCH}" + +if [ "$MAJOR" = "3" ]; then + IS_V3=true +else + IS_V3=false +fi + +echo "NEXT_VERSION=${NEXT_VERSION}" +echo "IS_V3=${IS_V3}" diff --git a/.github/scripts/next-version.test.sh b/.github/scripts/next-version.test.sh new file mode 100755 index 0000000..8989098 --- /dev/null +++ b/.github/scripts/next-version.test.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NEXT_VERSION_SH="$SCRIPT_DIR/next-version.sh" +FAILURES=0 + +assert_eq() { + local description="$1" expected="$2" actual="$3" + if [ "$expected" != "$actual" ]; then + echo "FAIL: $description (expected '$expected', got '$actual')" >&2 + FAILURES=$((FAILURES + 1)) + else + echo "PASS: $description" + fi +} + +new_temp_repo() { + local tmp + tmp=$(mktemp -d) + git init -q "$tmp" + git -C "$tmp" config user.email "test@example.com" + git -C "$tmp" config user.name "Test" + git -C "$tmp" commit -q --allow-empty -m "init" + echo "$tmp" +} + +# Test: no tags at all -> non-zero exit +repo=$(new_temp_repo) +if (cd "$repo" && bash "$NEXT_VERSION_SH" patch) >/dev/null 2>&1; then + echo "FAIL: expected failure with no tags present" >&2 + FAILURES=$((FAILURES + 1)) +else + echo "PASS: fails with no tags present" +fi +rm -rf "$repo" + +# Test: bootstrap from floating v3 tag, ignoring the requested bump +repo=$(new_temp_repo) +git -C "$repo" tag v3 +OUTPUT=$(cd "$repo" && bash "$NEXT_VERSION_SH" patch) +assert_eq "bootstrap ignores bump, uses floating major" "$(printf 'NEXT_VERSION=3.0.0\nIS_V3=true')" "$OUTPUT" +rm -rf "$repo" + +# Test: patch bump from full semver tag +repo=$(new_temp_repo) +git -C "$repo" tag v3.2.1 +OUTPUT=$(cd "$repo" && bash "$NEXT_VERSION_SH" patch) +assert_eq "patch bump" "$(printf 'NEXT_VERSION=3.2.2\nIS_V3=true')" "$OUTPUT" +rm -rf "$repo" + +# Test: minor bump resets patch +repo=$(new_temp_repo) +git -C "$repo" tag v3.2.1 +OUTPUT=$(cd "$repo" && bash "$NEXT_VERSION_SH" minor) +assert_eq "minor bump resets patch" "$(printf 'NEXT_VERSION=3.3.0\nIS_V3=true')" "$OUTPUT" +rm -rf "$repo" + +# Test: major bump resets minor/patch and flips IS_V3 to false +repo=$(new_temp_repo) +git -C "$repo" tag v3.2.1 +OUTPUT=$(cd "$repo" && bash "$NEXT_VERSION_SH" major) +assert_eq "major bump resets minor/patch" "$(printf 'NEXT_VERSION=4.0.0\nIS_V3=false')" "$OUTPUT" +rm -rf "$repo" + +# Test: a full semver tag takes priority over an existing floating tag +repo=$(new_temp_repo) +git -C "$repo" tag v3 +git -C "$repo" tag v3.2.1 +OUTPUT=$(cd "$repo" && bash "$NEXT_VERSION_SH" patch) +assert_eq "full semver tag wins over floating tag" "$(printf 'NEXT_VERSION=3.2.2\nIS_V3=true')" "$OUTPUT" +rm -rf "$repo" + +# Test: numeric sort, not lexicographic (v3.10.0 > v3.2.1) +repo=$(new_temp_repo) +git -C "$repo" tag v3.2.1 +git -C "$repo" tag v3.10.0 +OUTPUT=$(cd "$repo" && bash "$NEXT_VERSION_SH" patch) +assert_eq "numeric sort picks v3.10.0 over v3.2.1" "$(printf 'NEXT_VERSION=3.10.1\nIS_V3=true')" "$OUTPUT" +rm -rf "$repo" + +# Test: invalid bump type is rejected +repo=$(new_temp_repo) +git -C "$repo" tag v3.2.1 +if (cd "$repo" && bash "$NEXT_VERSION_SH" bogus) >/dev/null 2>&1; then + echo "FAIL: expected failure for invalid bump type" >&2 + FAILURES=$((FAILURES + 1)) +else + echo "PASS: rejects invalid bump type" +fi +rm -rf "$repo" + +if [ "$FAILURES" -gt 0 ]; then + echo "$FAILURES test(s) failed" >&2 + exit 1 +fi + +echo "All next-version.sh tests passed" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0bc9a8..34a68c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,22 @@ jobs: working-directory: ${{ matrix.working-directory }} run: npm run ci-test + test-release-scripts: + name: Release Scripts Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run next-version.sh tests + run: bash .github/scripts/next-version.test.sh + + - name: Run bump-major-version.sh tests + run: bash .github/scripts/bump-major-version.test.sh + test-python: name: Python Tests runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..06ddbff --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,125 @@ +name: Release + +on: + workflow_dispatch: + inputs: + bump: + description: Version part to bump + required: true + type: choice + options: + - patch + - minor + - major + dry_run: + description: Whether this is a dry run + required: true + type: boolean + default: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + release: + name: Release + runs-on: ubuntu-latest + environment: release + if: github.ref == 'refs/heads/main' + permissions: + contents: write + id-token: write + + steps: + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + id: app-token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + permission-contents: write + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + token: ${{ steps.app-token.outputs.token }} + fetch-depth: 0 + # Needed to push the tag and version bump commit in later steps. + persist-credentials: true + + - name: Setup + uses: $/setup + with: + aws_role_arn: ${{ secrets.AWS_ROLE_ARN }} + aws_region_name: ${{ vars.AWS_REGION_NAME }} + aws_secret_id: ${{ secrets.AWS_SECRET_ID }} + + - name: Determine next version + shell: bash + run: | + set +e + bash .github/scripts/next-version.sh "${{ inputs.bump }}" > /tmp/next-version.out 2> /tmp/next-version.err + STATUS=$? + set -e + if [ -s /tmp/next-version.err ]; then + cat /tmp/next-version.err + cat /tmp/next-version.err >> "$GITHUB_STEP_SUMMARY" + fi + if [ "$STATUS" -ne 0 ]; then + exit "$STATUS" + fi + cat /tmp/next-version.out >> "$GITHUB_ENV" + + - name: Bump the major version file + if: inputs.bump == 'major' + uses: $/bump-version + with: + version: ${{ env.NEXT_VERSION }} + version_bump_script: "bash .github/scripts/bump-major-version.sh" + push_commit: ${{ inputs.dry_run == false }} + + - name: Tag the version + uses: $/tag-version + with: + version: ${{ env.NEXT_VERSION }} + tag_template: "v${VERSION}" + push_tag: ${{ inputs.dry_run == false }} + + - name: Report floating v3 tag update (dry run) + if: env.IS_V3 == 'true' && inputs.dry_run == true + shell: bash + run: | + echo "[dry-run] Would move floating tag v3 to $(git rev-parse HEAD)" >> "$GITHUB_STEP_SUMMARY" + + - name: Create a new signed v3 tag + if: env.IS_V3 == 'true' && inputs.dry_run == false + uses: $/git-sign + with: + command: git tag -a "v3" -f -m "Update tag" -s --local-user=${{ env.GPG_KEY_ID }} + + - name: Push the v3 tag + if: env.IS_V3 == 'true' && inputs.dry_run == false + shell: bash -eux {0} + run: | + git push --force origin refs/tags/v3:refs/tags/v3 + + - name: Warn about floating tag retirement + if: env.IS_V3 != 'true' + shell: bash + run: | + MESSAGE="Release v${NEXT_VERSION} does not use floating tags. Remove the floating-tag-update steps (Create/Push v3 tag, and this warning) from .github/workflows/release.yml." + echo "::warning::$MESSAGE" + echo "$MESSAGE" >> "$GITHUB_STEP_SUMMARY" + + - name: Create GitHub Release + if: inputs.dry_run == false + shell: bash + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh release create "v${NEXT_VERSION}" --title "v${NEXT_VERSION}" --generate-notes + echo "Created release v${NEXT_VERSION}" >> "$GITHUB_STEP_SUMMARY" + + - name: Report release (dry run) + if: inputs.dry_run == true + shell: bash + run: | + echo "[dry-run] Would create tag v${NEXT_VERSION} and GitHub Release v${NEXT_VERSION}" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/update-action-tag.yml b/.github/workflows/update-action-tag.yml deleted file mode 100644 index 3dd4902..0000000 --- a/.github/workflows/update-action-tag.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Update Tag - -on: - workflow_dispatch: - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - -jobs: - update-tag: - name: Update Tag - runs-on: ubuntu-latest - environment: release - permissions: - contents: write - id-token: write - - steps: - - uses: actions/create-github-app-token@v3 - id: app-token - with: - app-id: ${{ vars.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - permission-contents: write - - - uses: actions/checkout@v7 - with: - token: ${{ steps.app-token.outputs.token }} - # Needed to push the tag in the final step - persist-credentials: true - - - name: Setup - uses: $/setup - with: - aws_role_arn: ${{ secrets.AWS_ROLE_ARN }} - aws_region_name: ${{ vars.AWS_REGION_NAME }} - aws_secret_id: ${{ secrets.AWS_SECRET_ID }} - - - name: Remove the existing tag - run: | - export VERSION=$(cat .github/workflows/version.txt) - echo "VERSION=$VERSION" >> $GITHUB_ENV - git push origin ":v${VERSION}" || true - - - name: Create a new signed tag - uses: $/git-sign - with: - command: git tag -a "v${{ env.VERSION }}" -m "Update tag" -s --local-user=${{ env.GPG_KEY_ID }} - - - name: Push the tag - run: - git push origin --tags \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e8f4212..52cc052 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,10 +26,12 @@ pre-commit run --all-files --hook-stage manual shellcheck ## Version Tag -To bump the version tag, run the "Update Tag" [workflow](https://github.com/mongodb-labs/drivers-github-tools/actions/workflows/update-action-tag.yml). +To cut a release, run the [Release workflow](https://github.com/mongodb-labs/drivers-github-tools/actions/workflows/release.yml) +with the desired `bump` input (`patch`, `minor`, or `major`). Use `dry_run: true` first to preview +the computed version before pushing anything. -To change the major version, update `.github/workflows/version.txt`. Internal +A `major` bump also updates `.github/workflows/version.txt` automatically. Internal action-to-action references use `$/` and do not need updating on a version bump. Update the example `mongodb-labs/drivers-github-tools/...@vX` references in `README.md` and `node/release_template.yml`, both of which document or use the -tag external consumers should pin to. \ No newline at end of file +tag external consumers should pin to — this remains a manual step after a major bump. \ No newline at end of file diff --git a/README.md b/README.md index bd80688..62e5ad1 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,12 @@ latest tagged version. For example, pinning `full-report` to an old sha means it also calls that old sha's `sbom`, `authorized-pub`, `code-scanning-export`, and `compliance-report`. Bump your pin to pick up sub-action updates too. +> [!NOTE] +> Through `v3`, this repo maintains floating major-version tags (e.g. `v3`) that move to the +> latest commit on `main`. Starting with `v4`, only immutable `vX.Y.Z` release tags are produced; +> there is no floating `v4` tag. Pin to an exact `vX.Y.Z` tag (or its resolved commit SHA) once you +> move to `v4`. + Example `dependabot.yml`: ```yaml