diff --git a/.github/workflows/pkg-go-release.yaml b/.github/workflows/pkg-go-release.yaml index 5a3b1c82..2c436783 100644 --- a/.github/workflows/pkg-go-release.yaml +++ b/.github/workflows/pkg-go-release.yaml @@ -13,22 +13,30 @@ jobs: uses: ./.github/workflows/pkg-go-build.yaml secrets: inherit - release: + # The release-please post-release job already created a draft GitHub release + # (CHANGELOG notes + auto-generated notes) when it pushed this tag. Go has no + # registry publish step, so once tests pass on the tag we publish (undraft) it. + finalize-release: runs-on: ubuntu-latest needs: [test] + if: startsWith(github.ref, 'refs/tags/') permissions: - contents: write - packages: write # publish a new github release + contents: write # publish (undraft) the github release steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - fetch-depth: 0 - - - uses: Roang-zero1/github-create-release-action@57eb9bdce7a964e48788b9e78b5ac766cb684803 # v3.0.1 - with: - version_regex: ^pkg/go/v[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+ - prerelease_regex: ^pkg/go/v[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+-(alpha|beta)\.[[:digit:]]+$ - changelog_file: pkg/go/CHANGELOG.md - create_draft: true + - name: Publish the draft GitHub release env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + # The draft is created right after the tag is pushed; wait briefly in + # case this job gets there first. + for _ in $(seq 1 10); do + if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then + break + fi + echo "Waiting for draft release $TAG ..." + sleep 6 + done + gh release edit "$TAG" --repo "$REPO" --draft=false diff --git a/.github/workflows/pkg-java-release.yaml b/.github/workflows/pkg-java-release.yaml index ddfd5828..b6fb3d1e 100644 --- a/.github/workflows/pkg-java-release.yaml +++ b/.github/workflows/pkg-java-release.yaml @@ -93,23 +93,30 @@ jobs: ORG_GRADLE_PROJECT_SIGNINGKEY: ${{ secrets.GPG_PRIVATE_KEY }} ORG_GRADLE_PROJECT_SIGNINGPASSWORD: ${{ secrets.GPG_PASSPHRASE }} - create-release: + # The release-please post-release job already created a draft GitHub release + # (CHANGELOG notes + auto-generated notes) when it pushed this tag. Publish + # (undraft) it only after both Maven publishes above have succeeded. + finalize-release: runs-on: ubuntu-latest needs: [publish-maven-central, publish-github-packages] + if: startsWith(github.ref, 'refs/tags/') permissions: - contents: write - packages: write # publish a new github release + contents: write # publish (undraft) the github release steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - fetch-depth: 0 - - - name: Create Release - uses: Roang-zero1/github-create-release-action@57eb9bdce7a964e48788b9e78b5ac766cb684803 # v3.0.1 - with: - version_regex: ^pkg/java/v[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+ - prerelease_regex: ^pkg/java/v[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+-(alpha|beta)\.[[:digit:]]+$ - changelog_file: pkg/java/CHANGELOG.md - create_draft: true + - name: Publish the draft GitHub release env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + # The draft is created right after the tag is pushed; wait briefly in + # case this job gets there first. + for _ in $(seq 1 10); do + if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then + break + fi + echo "Waiting for draft release $TAG ..." + sleep 6 + done + gh release edit "$TAG" --repo "$REPO" --draft=false diff --git a/.github/workflows/pkg-js-release.yaml b/.github/workflows/pkg-js-release.yaml index f973dd4c..f3587e4f 100644 --- a/.github/workflows/pkg-js-release.yaml +++ b/.github/workflows/pkg-js-release.yaml @@ -80,22 +80,30 @@ jobs: - name: Publish to npm run: npx -y npm@11.9.0 publish --tag ${{ steps.npm-tag.outputs.tag }} - release: + # The release-please post-release job already created a draft GitHub release + # (CHANGELOG notes + auto-generated notes) when it pushed this tag. Publish + # (undraft) it only after the npm publish above has succeeded. + finalize-release: runs-on: ubuntu-latest needs: [publish] + if: startsWith(github.ref, 'refs/tags/') permissions: - contents: write - packages: write # publish a new github release + contents: write # publish (undraft) the github release steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - fetch-depth: 0 - - - uses: Roang-zero1/github-create-release-action@57eb9bdce7a964e48788b9e78b5ac766cb684803 # v3.0.1 - with: - version_regex: ^pkg/js/v[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+ - prerelease_regex: ^pkg/js/v[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+-(alpha|beta|rc)\.[[:digit:]]+$ - changelog_file: pkg/js/CHANGELOG.md - create_draft: true + - name: Publish the draft GitHub release env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + # The draft is created right after the tag is pushed; wait briefly in + # case this job gets there first. + for _ in $(seq 1 10); do + if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then + break + fi + echo "Waiting for draft release $TAG ..." + sleep 6 + done + gh release edit "$TAG" --repo "$REPO" --draft=false diff --git a/.github/workflows/release-please.yaml b/.github/workflows/release-please.yaml new file mode 100644 index 00000000..2707a989 --- /dev/null +++ b/.github/workflows/release-please.yaml @@ -0,0 +1,60 @@ +name: Release Please + +on: + workflow_dispatch: + inputs: + target-package: + description: "Component to target" + type: choice + required: false + default: "all" + options: + - all + - pkg/go + - pkg/js + - pkg/java + bump-type: + description: "Version bump" + type: choice + required: false + default: "auto" + options: + - auto + - patch + - minor + - major + - explicit + release-version: + description: "Explicit version (only used when bump-type=explicit)" + type: string + required: false + default: "" + # Phase 2 (automatic): publish releases when a release commit lands on main. + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + uses: ./.github/workflows/reusable-release-please.yaml + permissions: + contents: write + pull-requests: write + with: + trigger-event: ${{ github.event_name == 'workflow_dispatch' && 'workflow_dispatch' || 'push' }} + target-package: ${{ inputs.target-package || 'all' }} + bump-type: ${{ inputs.bump-type || 'auto' }} + release-version: ${{ inputs.release-version || '' }} + secrets: + RELEASER_APP_CLIENT_ID: ${{ secrets.RELEASER_APP_CLIENT_ID }} + RELEASER_APP_PRIVATE_KEY: ${{ secrets.RELEASER_APP_PRIVATE_KEY }} + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + diff --git a/.github/workflows/reusable-release-please.yaml b/.github/workflows/reusable-release-please.yaml new file mode 100644 index 00000000..2a16dbd8 --- /dev/null +++ b/.github/workflows/reusable-release-please.yaml @@ -0,0 +1,316 @@ +name: reusable-release-please + +on: + workflow_call: + inputs: + trigger-event: + description: "What kicked off the run: workflow_dispatch | push" + type: string + required: true + target-package: + description: "Component to target: all | pkg/go | pkg/js | pkg/java" + type: string + required: false + default: "all" + bump-type: + description: "Version bump: auto | patch | minor | major | explicit" + type: string + required: false + default: "auto" + release-version: + description: "Explicit version to release (used when bump-type=explicit)" + type: string + required: false + default: "" + secrets: + RELEASER_APP_CLIENT_ID: + required: true + RELEASER_APP_PRIVATE_KEY: + required: true + GPG_PRIVATE_KEY: + required: true + GPG_PASSPHRASE: + required: false + +permissions: + contents: read + +jobs: + ########################################################################### + # Phase 1: create / retarget the release-please PR(s). + ########################################################################### + release-please: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Generate GitHub App token + id: app-token + # Pin to a SHA via your dependency tooling (Renovate/StepSecurity). + uses: actions/create-github-app-token@67e27a7eb7db372a1c61a7f9bdab8699e9ee57f7 # v2.1.4 + with: + app-id: ${{ secrets.RELEASER_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASER_APP_PRIVATE_KEY }} + + - name: Checkout (full history + tags) + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Configure git identity + run: | + git config user.name "openfga-releaser[bot]" + git config user.email "openfga-releaser[bot]@users.noreply.github.com" + + - name: Prepare dispatch run + if: inputs.trigger-event == 'workflow_dispatch' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + TARGET_PACKAGE: ${{ inputs.target-package }} + BUMP_TYPE: ${{ inputs.bump-type }} + RELEASE_VERSION: ${{ inputs.release-version }} + run: | + set -euo pipefail + PARSE=".github/workflows/scripts/parse-release.sh" + + git fetch origin main --tags --prune + + # 1) Reset the `release` branch from origin/main. + git branch -f release origin/main + git push -f origin release + + # 2) Ensure a baseline tag exists for every component in the manifest. + while read -r component version; do + tag="${component}/v${version}" + if ! git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + echo "Creating baseline tag ${tag}" + git tag "${tag}" origin/main + git push origin "refs/tags/${tag}" + fi + done < <(jq -r 'to_entries[] | "\(.key) \(.value)"' .release-please-manifest.json) + + # 3) Close stale release-please PRs. + for pr in $(gh pr list --state open \ + --search "head:release-please--branches--release" \ + --json number --jq '.[].number'); do + echo "Closing stale release PR #${pr}" + gh pr close "${pr}" --delete-branch || true + done + + # 4) Drop stale `autorelease: pending` labels on non-main merged PRs. + for pr in $(gh pr list --state merged --label "autorelease: pending" \ + --json number,baseRefName \ + --jq '.[] | select(.baseRefName != "main") | .number'); do + echo "Removing stale label from PR #${pr}" + gh pr edit "${pr}" --remove-label "autorelease: pending" || true + done + + # 5) Optionally force a version via a Release-As marker commit. + if [[ "${BUMP_TYPE}" != "auto" ]]; then + git checkout release + + components=() + if [[ "${TARGET_PACKAGE}" == "all" ]]; then + while read -r c; do components+=("$c"); done \ + < <(jq -r 'keys[]' .release-please-manifest.json) + else + components=("${TARGET_PACKAGE}") + fi + + for component in "${components[@]}"; do + current="$(jq -r --arg c "${component}" '.[$c]' .release-please-manifest.json)" + case "${BUMP_TYPE}" in + explicit) + newver="${RELEASE_VERSION}" + ;; + patch|minor|major) + newver="$("${PARSE}" next-version "${current}" "${BUMP_TYPE}")" + ;; + *) + echo "Unsupported bump-type: ${BUMP_TYPE}" >&2 + exit 1 + ;; + esac + + if [[ -z "${newver}" ]]; then + echo "Could not resolve a version for ${component}" >&2 + exit 1 + fi + + echo "Forcing ${component} -> ${newver} via Release-As" + marker="${component}/.release-please-trigger" + printf 'Release-As %s requested at %s\n' "${newver}" "$(date -u +%FT%TZ)" >"${marker}" + git add "${marker}" + git commit -m "chore(${component}): release ${newver}" -m "Release-As: ${newver}" + done + + git push -f origin release + fi + + - name: Resolve target branch + id: target + run: | + if [[ "${{ inputs.trigger-event }}" == "workflow_dispatch" ]]; then + echo "branch=release" >> "$GITHUB_OUTPUT" + else + echo "branch=main" >> "$GITHUB_OUTPUT" + fi + + - name: Run release-please + # We intentionally do not consume this action's outputs: skip-github-release + # is set in the config, so release/tag outputs are not produced. The + # post-release job derives everything from the manifest diff instead. + uses: googleapis/release-please-action@a02a34c4d625f9be7cb89156071d8567266a2445 # v4.2.0 + with: + token: ${{ steps.app-token.outputs.token }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + target-branch: ${{ steps.target.outputs.branch }} + + - name: Retarget release PRs onto main + if: inputs.trigger-event == 'workflow_dispatch' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + + # Move every release-please PR from the `release` branch onto `main` + # and rename its head ref `--branches--release` -> `--branches--main`. + for pr in $(gh pr list --state open \ + --search "head:release-please--branches--release" \ + --json number --jq '.[].number'); do + head="$(gh pr view "${pr}" --json headRefName --jq '.headRefName')" + new_head="${head//--branches--release/--branches--main}" + + echo "Retargeting PR #${pr} (${head} -> ${new_head}, base -> main)" + + # Recreate the head ref under the new name pointing at the same commit. + if [[ "${new_head}" != "${head}" ]]; then + sha="$(git rev-parse "origin/${head}" 2>/dev/null || echo "")" + if [[ -n "${sha}" ]]; then + git push origin "${sha}:refs/heads/${new_head}" --force + gh api --method PATCH "repos/${GITHUB_REPOSITORY}/pulls/${pr}" \ + -f head="${new_head}" >/dev/null 2>&1 || true + git push origin --delete "${head}" || true + fi + fi + + gh pr edit "${pr}" --base main || true + done + + ########################################################################### + # Phase 2: publish releases after a release commit lands on main. + ########################################################################### + post-release: + runs-on: ubuntu-latest + needs: [release-please] + if: >- + inputs.trigger-event == 'push' && + startsWith(github.event.head_commit.message, 'release:') + permissions: + contents: write + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@67e27a7eb7db372a1c61a7f9bdab8699e9ee57f7 # v2.1.4 + with: + app-id: ${{ secrets.RELEASER_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASER_APP_PRIVATE_KEY }} + + - name: Checkout (full history + tags) + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Import GPG key and enable signed tags + # Imports the key, caches the passphrase, and configures git so that + # `git tag -s` produces signed tags non-interactively in CI. + uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6.3.0 + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + passphrase: ${{ secrets.GPG_PASSPHRASE }} + git_user_signingkey: true + git_tag_gpgsign: true + + - name: Create signed tags and draft releases + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + PARSE=".github/workflows/scripts/parse-release.sh" + + # Diff the manifest committed by the release commit against its parent. + git show "HEAD~1:.release-please-manifest.json" > /tmp/prev-manifest.json 2>/dev/null \ + || echo '{}' > /tmp/prev-manifest.json + cp .release-please-manifest.json /tmp/cur-manifest.json + + # manifest-diff exits non-zero when nothing changed; treat that as a + # no-op rather than a failure. + if ! releases="$("${PARSE}" manifest-diff /tmp/cur-manifest.json /tmp/prev-manifest.json 2>/dev/null)"; then + echo "No changed components; nothing to release." + exit 0 + fi + echo "Releases to publish: ${releases}" + + count="$(jq 'length' <<<"${releases}")" + + for i in $(seq 0 $((count - 1))); do + component="$(jq -r ".[$i].component" <<<"${releases}")" + version="$(jq -r ".[$i].version" <<<"${releases}")" + tag="$(jq -r ".[$i].tag_name" <<<"${releases}")" + + # 1) Ensure the signed tag exists. + if ! git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + echo "Creating signed tag ${tag}" + git tag -s "${tag}" -m "Release ${tag}" + git push origin "refs/tags/${tag}" + else + echo "Tag ${tag} already exists" + fi + + # 2) Notes from the package CHANGELOG. + NOTES="$("${PARSE}" changelog-notes "${component}/CHANGELOG.md" "${version}" || true)" + + # 3) GitHub auto-generated notes (previous tag = previous version of + # the same component, when one exists). + PREV_TAG="$(git tag --list "${component}/v*" --sort=-version:refname \ + | grep -vx "${tag}" | head -1 || true)" + GEN_PAYLOAD=$(jq -nc \ + --arg tag "${tag}" \ + --arg target "main" \ + --arg prev "${PREV_TAG}" \ + '{tag_name: $tag, target_commitish: $target} + + (if $prev != "" then {previous_tag_name: $prev} else {} end)') + + AUTO_NOTES=$(gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + "repos/${REPO}/releases/generate-notes" \ + --input - <<<"${GEN_PAYLOAD}" \ + --jq '.body' 2>/dev/null || true) + + # 4) Combine: changelog notes + blank line + API notes. + if [[ -n "${AUTO_NOTES}" ]]; then + NOTES=$(printf '%s\n\n%s\n' "${NOTES}" "${AUTO_NOTES}") + fi + + # 5) Create a draft GitHub release. + if gh release view "${tag}" >/dev/null 2>&1; then + echo "Release ${tag} already exists; skipping" + else + echo "Creating draft release ${tag}" + gh release create "${tag}" --title "${tag}" --notes "${NOTES}" --draft + fi + done + + # Keep the `release` branch in sync with main for the next dispatch run. + git push -f origin "HEAD:refs/heads/release" + + + + + diff --git a/.github/workflows/scripts/parse-release.sh b/.github/workflows/scripts/parse-release.sh new file mode 100755 index 00000000..a15d7692 --- /dev/null +++ b/.github/workflows/scripts/parse-release.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Extracted from .github/workflows/reusable-release-please.yaml so the parsing +# logic can be unit-tested independently of GitHub Actions. +# +# Usage: +# parse-release.sh manifest-diff +# -> prints JSON array of {component, version, tag_name} for changed pkgs +# exits 1 if no changes detected +# +# parse-release.sh changelog-notes +# -> prints the release notes section for the given version +# falls back to "Release " if no section found +# +# parse-release.sh next-version +# -> prints the next semantic version after for the given +# bump type (pre-release/build metadata on is dropped). +# Used by the workflow_dispatch path to force a Release-As version. +set -euo pipefail + +cmd="${1:-}" +shift || true + +case "$cmd" in + manifest-diff) + current_file="$1" + previous_file="$2" + + releases=$(jq -c -n \ + --argjson cur "$(cat "$current_file")" \ + --argjson prev "$(cat "$previous_file")" \ + '[ $cur | to_entries[] + | select(.value != $prev[.key]) + | { component: .key, version: .value, tag_name: "\(.key)/v\(.value)" } ]') + + if [[ $(jq 'length' <<<"$releases") -eq 0 ]]; then + echo "::error::No version changes detected." >&2 + exit 1 + fi + + echo "$releases" + ;; + + changelog-notes) + changelog="$1" + version="$2" + + notes=$(awk -v ver="$version" ' + BEGIN { + gsub(/\./, "\\.", ver) + pattern = "(^|[^0-9A-Za-z.-])" ver "([^0-9A-Za-z.-]|$)" + } + /^## / { + if (found) exit + if ($0 ~ pattern) { found=1; next } + } + found { print } + ' "$changelog") + + if [[ -z "$notes" ]]; then + echo "Release ${version}" + else + echo "$notes" + fi + ;; + + next-version) + base="$1" + bump="$2" + + # Strip a leading "v" and any pre-release / build metadata. + base="${base#v}" + base="${base%%-*}" + base="${base%%+*}" + + IFS='.' read -r major minor patch <<<"$base" + + case "$bump" in + major) major=$((major + 1)); minor=0; patch=0 ;; + minor) minor=$((minor + 1)); patch=0 ;; + patch) patch=$((patch + 1)) ;; + *) echo "Usage: $0 next-version " >&2; exit 2 ;; + esac + + echo "${major}.${minor}.${patch}" + ;; + + *) + echo "Usage: $0 {manifest-diff|changelog-notes|next-version} ..." >&2 + exit 2 + ;; +esac + diff --git a/.github/workflows/scripts/parse-release.test.sh b/.github/workflows/scripts/parse-release.test.sh new file mode 100755 index 00000000..68f87203 --- /dev/null +++ b/.github/workflows/scripts/parse-release.test.sh @@ -0,0 +1,394 @@ +#!/usr/bin/env bash +# Tests for parse-release.sh +# Run: bash .github/workflows/scripts/parse-release.test.sh + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PARSE="$SCRIPT_DIR/parse-release.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +PASS=0 +FAIL=0 + +assert_eq() { + local name="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then + echo " PASS - $name" + PASS=$((PASS + 1)) + else + echo " FAIL - $name" + echo " expected: $expected" + echo " actual: $actual" + FAIL=$((FAIL + 1)) + fi +} + +assert_contains() { + local name="$1" needle="$2" haystack="$3" + if [[ "$haystack" == *"$needle"* ]]; then + echo " PASS - $name" + PASS=$((PASS + 1)) + else + echo " FAIL - $name" + echo " expected to contain: $needle" + echo " actual: $haystack" + FAIL=$((FAIL + 1)) + fi +} + +assert_exit_code() { + local name="$1" expected="$2" actual="$3" + if [[ "$expected" -eq "$actual" ]]; then + echo " PASS - $name (exit=$actual)" + PASS=$((PASS + 1)) + else + echo " FAIL - $name" + echo " expected exit: $expected" + echo " actual exit: $actual" + FAIL=$((FAIL + 1)) + fi +} + +##################################################################### +# manifest-diff tests +##################################################################### +echo +echo "=== manifest-diff ===" + +diff_run() { + echo "$1" >"$TMP/cur.json" + echo "$2" >"$TMP/prev.json" + "$PARSE" manifest-diff "$TMP/cur.json" "$TMP/prev.json" 2>/dev/null +} + +# 1. Only pkg/go bumped +out=$(diff_run \ + '{"pkg/go":"0.3.0","pkg/js":"0.2.4","pkg/java":"0.2.0-beta.0"}' \ + '{"pkg/go":"0.2.2","pkg/js":"0.2.4","pkg/java":"0.2.0-beta.0"}') +assert_eq "only pkg/go bumped" \ + '[{"component":"pkg/go","version":"0.3.0","tag_name":"pkg/go/v0.3.0"}]' \ + "$out" + +# 2. Only pkg/js bumped +out=$(diff_run \ + '{"pkg/go":"0.2.2","pkg/js":"0.2.5","pkg/java":"0.2.0-beta.0"}' \ + '{"pkg/go":"0.2.2","pkg/js":"0.2.4","pkg/java":"0.2.0-beta.0"}') +assert_eq "only pkg/js bumped" \ + '[{"component":"pkg/js","version":"0.2.5","tag_name":"pkg/js/v0.2.5"}]' \ + "$out" + +# 3. Only pkg/java bumped +out=$(diff_run \ + '{"pkg/go":"0.2.2","pkg/js":"0.2.4","pkg/java":"0.2.0-beta.1"}' \ + '{"pkg/go":"0.2.2","pkg/js":"0.2.4","pkg/java":"0.2.0-beta.0"}') +assert_eq "only pkg/java bumped (pre-release)" \ + '[{"component":"pkg/java","version":"0.2.0-beta.1","tag_name":"pkg/java/v0.2.0-beta.1"}]' \ + "$out" + +# 4. Two packages bumped simultaneously +out=$(diff_run \ + '{"pkg/go":"0.3.0","pkg/js":"0.2.5","pkg/java":"0.2.0-beta.0"}' \ + '{"pkg/go":"0.2.2","pkg/js":"0.2.4","pkg/java":"0.2.0-beta.0"}') +assert_eq "pkg/go + pkg/js bumped together" \ + '[{"component":"pkg/go","version":"0.3.0","tag_name":"pkg/go/v0.3.0"},{"component":"pkg/js","version":"0.2.5","tag_name":"pkg/js/v0.2.5"}]' \ + "$out" + +# 5. All three packages bumped +out=$(diff_run \ + '{"pkg/go":"0.3.0","pkg/js":"0.3.0","pkg/java":"0.3.0"}' \ + '{"pkg/go":"0.2.2","pkg/js":"0.2.4","pkg/java":"0.2.0-beta.0"}') +n=$(echo "$out" | jq 'length') +assert_eq "all three packages bumped (count=3)" "3" "$n" + +# 6. No changes; expect exit 1 with error +echo '{"pkg/go":"0.2.2","pkg/js":"0.2.4","pkg/java":"0.2.0-beta.0"}' >"$TMP/cur.json" +cp "$TMP/cur.json" "$TMP/prev.json" +err=$("$PARSE" manifest-diff "$TMP/cur.json" "$TMP/prev.json" 2>&1 >/dev/null) +code=$? +assert_exit_code "no changes exits non-zero" 1 "$code" +assert_contains "no changes error message" "No version changes detected" "$err" + +# 7. New package added (not in previous); expect a release +out=$(diff_run \ + '{"pkg/go":"0.2.2","pkg/js":"0.2.4","pkg/java":"0.2.0-beta.0","pkg/rust":"0.1.0"}' \ + '{"pkg/go":"0.2.2","pkg/js":"0.2.4","pkg/java":"0.2.0-beta.0"}') +assert_eq "new package added to manifest" \ + '[{"component":"pkg/rust","version":"0.1.0","tag_name":"pkg/rust/v0.1.0"}]' \ + "$out" + +# 8. Package removed from manifest is ignored (not in current) +echo '{"pkg/go":"0.2.2","pkg/js":"0.2.4"}' >"$TMP/cur.json" +echo '{"pkg/go":"0.2.2","pkg/js":"0.2.4","pkg/java":"0.2.0-beta.0"}' >"$TMP/prev.json" +"$PARSE" manifest-diff "$TMP/cur.json" "$TMP/prev.json" >/dev/null 2>&1 +code=$? +assert_exit_code "removed package does not trigger release" 1 "$code" + +# 9. Pre-release to stable bump +out=$(diff_run \ + '{"pkg/java":"1.0.0"}' \ + '{"pkg/java":"1.0.0-rc.1"}') +assert_eq "pre-release to stable bump" \ + '[{"component":"pkg/java","version":"1.0.0","tag_name":"pkg/java/v1.0.0"}]' \ + "$out" + +# 10. Patch bump that overlaps a substring of the older version +out=$(diff_run \ + '{"pkg/js":"0.2.40"}' \ + '{"pkg/js":"0.2.4"}') +assert_eq "version 0.2.4 -> 0.2.40 detected as change" \ + '[{"component":"pkg/js","version":"0.2.40","tag_name":"pkg/js/v0.2.40"}]' \ + "$out" + +##################################################################### +# changelog-notes tests +##################################################################### +echo +echo "=== changelog-notes ===" + +cat >"$TMP/CHANGELOG.md" <<'EOF' +# Changelog + +## Unreleased + +## [0.2.40](https://github.com/foo/bar/compare/pkg/js/v0.2.39...pkg/js/v0.2.40) (2026-06-01) + + +### Added + +* feature for 0.2.40 ([#999](https://github.com/foo/bar/issues/999)) + + +## [0.2.4](https://github.com/foo/bar/compare/pkg/js/v0.2.3...pkg/js/v0.2.4) (2026-05-28) + +> [!NOTE] +> Manual note added between version headings. + +### Fixed + +* bug fix for 0.2.4 ([#283](https://github.com/foo/bar/issues/283)) + + +## [0.2.3](https://github.com/foo/bar/compare/pkg/js/v0.2.2...pkg/js/v0.2.3) (2026-05-26) + + +### Miscellaneous + +* release 0.2.3 + + +## [0.2.0-beta.1](https://github.com/foo/bar/compare/pkg/js/v0.2.0-beta.0...pkg/js/v0.2.0-beta.1) (2026-04-10) + + +### Added + +* beta feature +EOF + +# 1. Latest version captures its own section and ### subheadings +out=$("$PARSE" changelog-notes "$TMP/CHANGELOG.md" "0.2.40") +assert_contains "0.2.40 captures ### Added subheading" "### Added" "$out" +assert_contains "0.2.40 captures its body" "feature for 0.2.40" "$out" +if [[ "$out" != *"0.2.4 "* && "$out" != *"v0.2.4)"* ]]; then + echo " PASS - 0.2.40 does not leak into 0.2.4 section" + PASS=$((PASS + 1)) +else + echo " FAIL - 0.2.40 leaked into 0.2.4 section: $out" + FAIL=$((FAIL + 1)) +fi + +# 2. Substring collision: 0.2.4 must not capture 0.2.40 +out=$("$PARSE" changelog-notes "$TMP/CHANGELOG.md" "0.2.4") +assert_contains "0.2.4 captures manual NOTE block" "Manual note added" "$out" +assert_contains "0.2.4 captures ### Fixed" "### Fixed" "$out" +assert_contains "0.2.4 captures bug fix line" "bug fix for 0.2.4" "$out" +if [[ "$out" == *"feature for 0.2.40"* ]]; then + echo " FAIL - 0.2.4 incorrectly captured 0.2.40 content" + FAIL=$((FAIL + 1)) +else + echo " PASS - 0.2.4 does not capture 0.2.40 content" + PASS=$((PASS + 1)) +fi + +# 3. Older version +out=$("$PARSE" changelog-notes "$TMP/CHANGELOG.md" "0.2.3") +assert_contains "0.2.3 captures Miscellaneous section" "release 0.2.3" "$out" + +# 4. Pre-release version +out=$("$PARSE" changelog-notes "$TMP/CHANGELOG.md" "0.2.0-beta.1") +assert_contains "pre-release 0.2.0-beta.1 captured" "beta feature" "$out" + +# 5. A version that only appears in a compare URL must not match a heading +out=$("$PARSE" changelog-notes "$TMP/CHANGELOG.md" "0.2.39") +assert_eq "URL-only version falls back to default" "Release 0.2.39" "$out" + +# 6. Missing version falls back +out=$("$PARSE" changelog-notes "$TMP/CHANGELOG.md" "9.9.9") +assert_eq "missing version falls back" "Release 9.9.9" "$out" + +# 7. GitHub markdown alerts must be preserved verbatim +cat >"$TMP/ALERTS.md" <<'EOF' +# Changelog + +## [1.0.0](https://github.com/foo/bar/compare/v0.9.0...v1.0.0) (2026-07-01) + +> [!WARNING] +> Breaking change: API endpoint renamed. + +> [!IMPORTANT] +> You must run migrations before upgrading. + +> [!TIP] +> See the migration guide for details. + +> [!CAUTION] +> Do not skip the manual step in section 4. + +> [!NOTE] +> This release was tested on Linux, macOS, and Windows. + +### Added + +* new shiny feature + +## [0.9.0](https://github.com/foo/bar/compare/v0.8.0...v0.9.0) (2026-06-15) + +prior content +EOF + +out=$("$PARSE" changelog-notes "$TMP/ALERTS.md" "1.0.0") +for kind in WARNING IMPORTANT TIP CAUTION NOTE; do + assert_contains "alert [!${kind}] preserved" "[!${kind}]" "$out" +done +assert_contains "alert body line preserved" "Breaking change: API endpoint renamed." "$out" +assert_contains "trailing section after alerts preserved" "new shiny feature" "$out" +if [[ "$out" == *"prior content"* ]]; then + echo " FAIL - capture leaked into 0.9.0 section" + FAIL=$((FAIL + 1)) +else + echo " PASS - capture stops cleanly at next version heading" + PASS=$((PASS + 1)) +fi + +##################################################################### +# explicit / pre-release version handling +# Explicit bumps (e.g. 1.5.8-beta.1) are released verbatim, so manifest-diff +# and changelog-notes must handle pre-release identifiers without colliding +# with the matching stable version. +##################################################################### +echo +echo "=== explicit / pre-release versions ===" + +# 11. Pre-release identifier bump (beta.1 -> beta.2) +out=$(diff_run \ + '{"pkg/go":"1.5.8-beta.2"}' \ + '{"pkg/go":"1.5.8-beta.1"}') +assert_eq "pre-release identifier bump (beta.1 -> beta.2)" \ + '[{"component":"pkg/go","version":"1.5.8-beta.2","tag_name":"pkg/go/v1.5.8-beta.2"}]' \ + "$out" + +# 12. Stable to pre-release of the next version +out=$(diff_run \ + '{"pkg/js":"1.5.8-beta.1"}' \ + '{"pkg/js":"1.5.7"}') +assert_eq "stable -> pre-release of next version" \ + '[{"component":"pkg/js","version":"1.5.8-beta.1","tag_name":"pkg/js/v1.5.8-beta.1"}]' \ + "$out" + +# 13. Numeric ordering is irrelevant (string compare): beta.9 -> beta.10 +out=$(diff_run \ + '{"pkg/java":"2.0.0-beta.10"}' \ + '{"pkg/java":"2.0.0-beta.9"}') +assert_eq "beta.9 -> beta.10 detected (string compare)" \ + '[{"component":"pkg/java","version":"2.0.0-beta.10","tag_name":"pkg/java/v2.0.0-beta.10"}]' \ + "$out" + +# 14. Same pre-release version unchanged; no release +echo '{"pkg/go":"3.0.0-rc.1"}' >"$TMP/cur.json" +cp "$TMP/cur.json" "$TMP/prev.json" +"$PARSE" manifest-diff "$TMP/cur.json" "$TMP/prev.json" >/dev/null 2>&1 +code=$? +assert_exit_code "unchanged pre-release does not trigger release" 1 "$code" + +cat >"$TMP/PRE.md" <<'EOF' +# Changelog + +## [1.5.8](https://github.com/foo/bar/compare/v1.5.8-beta.2...v1.5.8) (2026-06-04) + +### Fixed + +* stable fix for 1.5.8 + +## [1.5.8-beta.2](https://github.com/foo/bar/compare/v1.5.8-beta.1...v1.5.8-beta.2) (2026-06-03) + +### Added + +* second beta feature + +## [1.5.8-beta.1](https://github.com/foo/bar/compare/v1.5.7...v1.5.8-beta.1) (2026-06-02) + +### Added + +* first beta feature + +## [1.0.0-alpha.10](https://github.com/foo/bar/compare/v1.0.0-alpha.9...v1.0.0-alpha.10) (2026-05-01) + +### Added + +* alpha ten feature +EOF + +# 15. Exact pre-release match captures its own body, no leak from newer beta +out=$("$PARSE" changelog-notes "$TMP/PRE.md" "1.5.8-beta.1") +assert_contains "beta.1 captures its own body" "first beta feature" "$out" +if [[ "$out" == *"second beta feature"* ]]; then + echo " FAIL - beta.1 leaked beta.2 content" + FAIL=$((FAIL + 1)) +else + echo " PASS - beta.1 does not leak beta.2 content" + PASS=$((PASS + 1)) +fi + +# 16. Newer beta captures its own body, stops before older beta +out=$("$PARSE" changelog-notes "$TMP/PRE.md" "1.5.8-beta.2") +assert_contains "beta.2 captures its own body" "second beta feature" "$out" +if [[ "$out" == *"first beta feature"* ]]; then + echo " FAIL - beta.2 leaked beta.1 content" + FAIL=$((FAIL + 1)) +else + echo " PASS - beta.2 does not leak beta.1 content" + PASS=$((PASS + 1)) +fi + +# 17. Stable 1.5.8 must not match the 1.5.8-beta.* headings (boundary on '-') +out=$("$PARSE" changelog-notes "$TMP/PRE.md" "1.5.8") +assert_contains "stable 1.5.8 captures stable body" "stable fix for 1.5.8" "$out" +if [[ "$out" == *"beta feature"* ]]; then + echo " FAIL - stable 1.5.8 incorrectly captured a beta section" + FAIL=$((FAIL + 1)) +else + echo " PASS - stable 1.5.8 does not capture beta sections" + PASS=$((PASS + 1)) +fi + +# 18. Double-digit pre-release identifier (alpha.10) +out=$("$PARSE" changelog-notes "$TMP/PRE.md" "1.0.0-alpha.10") +assert_contains "alpha.10 captured exactly" "alpha ten feature" "$out" + +# 19. Partial pre-release prefix must not match (1.5.8-beta -> fallback) +out=$("$PARSE" changelog-notes "$TMP/PRE.md" "1.5.8-beta") +assert_eq "partial pre-release prefix falls back" "Release 1.5.8-beta" "$out" + +##################################################################### +# summary +##################################################################### +echo +echo "================================" +echo " Passed: $PASS" +echo " Failed: $FAIL" +echo "================================" + +if [[ "$FAIL" -gt 0 ]]; then + exit 1 +fi \ No newline at end of file diff --git a/.github/workflows/test-release-scripts.yml b/.github/workflows/test-release-scripts.yml new file mode 100644 index 00000000..a752ff0d --- /dev/null +++ b/.github/workflows/test-release-scripts.yml @@ -0,0 +1,25 @@ +name: Test release parsing scripts + +on: + pull_request: + paths: + - .github/workflows/scripts/parse-release.sh + - .github/workflows/scripts/parse-release.test.sh + - .github/workflows/test-release-scripts.yml + push: + branches: [main] + paths: + - .github/workflows/scripts/parse-release.sh + - .github/workflows/scripts/parse-release.test.sh + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Run parse-release tests + run: bash .github/workflows/scripts/parse-release.test.sh + diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 00000000..a063489a --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,6 @@ +{ + "pkg/go": "0.2.1", + "pkg/js": "0.2.1", + "pkg/java": "0.2.0-beta.2" +} + diff --git a/pkg/java/build.gradle b/pkg/java/build.gradle index 114ed2fb..80c23e5f 100644 --- a/pkg/java/build.gradle +++ b/pkg/java/build.gradle @@ -19,7 +19,7 @@ plugins { apply from: 'publish.gradle' group = 'dev.openfga' -version = 'v0.2.0-beta.0' +version = '0.2.0-beta.2' // x-release-please-version repositories { mavenCentral() diff --git a/pkg/js/.prettierignore b/pkg/js/.prettierignore index 222e0aeb..f09472d4 100644 --- a/pkg/js/.prettierignore +++ b/pkg/js/.prettierignore @@ -1,3 +1,5 @@ tests/__snapshots__/* gen -.npm-upgrade.json \ No newline at end of file +.npm-upgrade.json +# Generated by release-please; formatting is managed by the release tooling. +CHANGELOG.md diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 00000000..20dedba4 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "separate-pull-requests": true, + "pull-request-title-pattern": "release: ${component} v${version}", + "skip-github-release": true, + "include-component-in-tag": true, + "tag-separator": "/", + "changelog-path": "CHANGELOG.md", + "changelog-type": "default", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "changelog-sections": [ + { "type": "feat", "section": "Added", "hidden": false }, + { "type": "fix", "section": "Fixed", "hidden": false }, + { "type": "perf", "section": "Changed", "hidden": false }, + { "type": "refactor", "section": "Changed", "hidden": false }, + { "type": "revert", "section": "Removed", "hidden": false }, + { "type": "docs", "section": "Documentation", "hidden": false }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "ci", "section": "CI", "hidden": true }, + { "type": "chore", "section": "Miscellaneous", "hidden": true }, + { "type": "release", "section": "Miscellaneous", "hidden": true } + ], + "packages": { + "pkg/go": { + "release-type": "go", + "component": "pkg/go", + "package-name": "pkg/go" + }, + "pkg/js": { + "release-type": "node", + "component": "pkg/js", + "package-name": "@openfga/syntax-transformer" + }, + "pkg/java": { + "release-type": "java", + "component": "pkg/java", + "package-name": "openfga-language", + "extra-files": ["build.gradle"] + } + } +} + + +