diff --git a/.github/blocks/bump-monorepo-versions/action.yaml b/.github/blocks/bump-monorepo-versions/action.yaml index de19bd5..dfc8919 100644 --- a/.github/blocks/bump-monorepo-versions/action.yaml +++ b/.github/blocks/bump-monorepo-versions/action.yaml @@ -90,12 +90,28 @@ runs: PKG_COUNT=$(echo "$CHANGED_JSON" | jq 'length') PROMPT_PARTS="" + # Resolve a valid base once: the last release if it is an ancestor of + # HEAD, else the repo root. Used unguarded below so a genuine git failure + # stops the run loudly instead of publishing on an empty diff. + if git merge-base --is-ancestor "$LAST_SHA" HEAD 2>/dev/null; then + BASE="$LAST_SHA" + else + BASE=$(git rev-list --max-parents=0 HEAD | tail -1) + fi + for i in $(seq 0 $(( PKG_COUNT - 1 ))); do NAME=$(echo "$CHANGED_JSON" | jq -r ".[$i].name") PKG_PATH=$(echo "$CHANGED_JSON" | jq -r ".[$i].path") CURRENT_VER=$(jq -r '.version // "0.0.0"' "$PKG_PATH/package.json") - COMMITS=$(git log --oneline "${LAST_SHA}..HEAD" -- "$PKG_PATH" 2>/dev/null || git log --oneline HEAD~10..HEAD -- "$PKG_PATH") + COMMITS=$(git log --oneline "$BASE..HEAD" -- "$PKG_PATH") + NAME_STATUS=$(git diff --name-status "$BASE..HEAD" -- "$PKG_PATH") + PKG_DIFF=$(git diff "$BASE..HEAD" -- "$PKG_PATH") + CAP=100000 + if [ "${#PKG_DIFF}" -gt "$CAP" ]; then + PKG_DIFF="${PKG_DIFF:0:$CAP} + (diff truncated at ${CAP} chars — run: git diff $BASE..HEAD -- ${PKG_PATH} for the full diff)" + fi PROMPT_PARTS="${PROMPT_PARTS} Package: ${NAME} @@ -103,6 +119,10 @@ runs: Path: ${PKG_PATH} Commits: ${COMMITS} + Files changed (A=added M=modified D=deleted R=renamed): + ${NAME_STATUS} + Diff: + ${PKG_DIFF} ---" done @@ -117,18 +137,29 @@ runs: with: openai-api-key: ${{ inputs.openai-api-key }} safety-strategy: read-only + effort: high prompt: | You are a release manager for the "${{ inputs.service-name }}" monorepo. Prerelease: ${{ inputs.prerelease }} - For each package below, determine the next semantic version based on its commits. - Rules: - - BREAKING CHANGE or ! after type = major bump - - feat: = minor bump - - fix:, perf:, or other = patch bump - - Default to patch if unclear - - Each package is versioned independently - - For prereleases: use same version logic, suffix will be added automatically + For each package below, determine the next semantic version. Base the + decision on the ACTUAL CODE CHANGES — the commits, the changed-file + list, and the diff are all provided per package. Commit messages are a + hint, not the source of truth: most authors do not annotate breaking + changes, so the diff is authoritative. + + Rules (apply in order, per package, versioned independently): + - MAJOR — an explicit `BREAKING CHANGE:` footer or `type!:` marker, OR a + diff that is backward-incompatible even without one (a public/exported + symbol or entry point removed or renamed, a schema/required field or + return shape changed, default behavior or a config contract changed). + You are explicitly allowed to bump the major from the diff alone. + - MINOR — backward-compatible new functionality is ADDED (feat:). + - PATCH — fixes, perf, refactors, docs, chores; nothing public removed + or broken. + Be conservative about MAJOR: purely additive changes are MINOR, and when + genuinely unclear pick the lower bump. For prereleases the -rc suffix is + added automatically. ${{ steps.commit-logs.outputs.prompt }} diff --git a/.github/blocks/determine-publish-version/action.yaml b/.github/blocks/determine-publish-version/action.yaml index 086c686..a967abd 100644 --- a/.github/blocks/determine-publish-version/action.yaml +++ b/.github/blocks/determine-publish-version/action.yaml @@ -64,36 +64,115 @@ runs: core.setOutput('version', '0.0.0'); } - // For classification: prefer the last stable. Falls back to - // `0.0.0` if no stable release exists yet (initial-release path). - core.setOutput( - 'stable_version', - lastStable ? lastStable.tag_name.replace(/^v/, '') : '0.0.0' - ); + // Last stable: its SHA is the base for both the diff range and the + // version computation (so rc-to-rc publishes stay on the same core + // line instead of advancing off a prerelease tag), and its version + // is the classification baseline. Empty/0.0.0 on the initial release. + if (lastStable) { + const { data: sref } = await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${lastStable.tag_name}` + }); + core.setOutput('stable_sha', sref.object.sha); + core.setOutput('stable_version', lastStable.tag_name.replace(/^v/, '')); + } else { + core.setOutput('stable_sha', ''); + core.setOutput('stable_version', '0.0.0'); + } } catch (error) { core.setOutput('sha', context.sha); core.setOutput('version', '0.0.0'); + core.setOutput('stable_sha', ''); core.setOutput('stable_version', '0.0.0'); } + - name: Gather change context + id: context + shell: bash + env: + BASE_SHA: ${{ steps.last-release.outputs.stable_sha }} + HEAD_SHA: ${{ github.sha }} + run: | + set -euo pipefail + # No prior stable release -> diff against the empty tree so the entire + # history (including the root commit) is included for the initial release. + if [ -n "$BASE_SHA" ]; then + DIFF_BASE="$BASE_SHA"; LOG_RANGE="$BASE_SHA..$HEAD_SHA" + else + DIFF_BASE=$(git hash-object -t tree /dev/null); LOG_RANGE="$HEAD_SHA" + fi + clip() { if [ "${#1}" -gt "$2" ]; then printf '%s\n(...truncated at %s chars; run git diff for the rest...)' "${1:0:$2}" "$2"; else printf '%s' "$1"; fi; } + NAME_STATUS=$(clip "$(git diff --name-status "$DIFF_BASE" "$HEAD_SHA")" 60000) + STAT=$(clip "$(git diff --stat "$DIFF_BASE" "$HEAD_SHA")" 40000) + LOG=$(clip "$(git log --no-merges --format='- %s%n%b' $LOG_RANGE)" 100000) + DIFF=$(clip "$(git diff "$DIFF_BASE" "$HEAD_SHA")" 400000) + MARKER=$(openssl rand -hex 16) + { + echo "context<<${MARKER}" + echo "## Commit messages" + echo "$LOG" + echo + echo "## Files changed (A=added M=modified D=deleted R=renamed)" + echo "$NAME_STATUS" + echo + echo "## Churn summary" + echo "$STAT" + echo + echo "## Full diff" + echo '```diff' + echo "$DIFF" + echo '```' + echo "${MARKER}" + } >> "$GITHUB_OUTPUT" + - name: AI Determine Version id: ai-version uses: openai/codex-action@v1 with: openai-api-key: ${{ inputs.openai-api-key }} safety-strategy: read-only + effort: high prompt: | - Analyze commits between ${{ steps.last-release.outputs.sha }} and ${{ github.sha }}. - Current version: ${{ steps.last-release.outputs.version }} + Determine the next semantic version after ${{ steps.last-release.outputs.stable_version }}. + Current version: ${{ steps.last-release.outputs.stable_version }} Prerelease: ${{ inputs.prerelease }} - - Rules: - - BREAKING CHANGE or ! = major bump - - feat: = minor bump - - fix:, perf:, or other = patch bump - - Default to patch if unclear - - For prereleases: use same version logic, suffix will be added automatically - + + Base your decision on the ACTUAL CODE CHANGES below — the commit + messages, the list of changed files, and the diff are all provided. + Commit messages are a hint, not the source of truth: most authors do + not annotate breaking changes, so the diff is authoritative. If the diff + was truncated you may run `git diff` yourself (read-only) for any file. + + How to choose the bump (apply in order): + 1. MAJOR — if a commit message carries an explicit `BREAKING CHANGE:` + footer or a `type!:` marker, that is authoritative: bump major. + ALSO bump major if the DIFF itself is backward-incompatible even when + no commit said so — e.g. a public endpoint/route/exported symbol is + removed or renamed, a request/response schema or required field + changes shape, default behavior changes in a way that breaks existing + callers, or a config/runtime contract changes. You are explicitly + allowed to bump the major from the diff alone. + 2. MINOR — backward-COMPATIBLE new functionality: a new endpoint, flag, + exported symbol, or capability is ADDED without breaking existing + ones (corresponds to `feat:`). + 3. PATCH — bug fixes, performance, internal refactors, docs, chores, or + anything else that neither adds public surface nor breaks it. + + Be conservative about MAJOR to avoid false positives: purely additive + changes are MINOR, not MAJOR. Only call it breaking if existing public + behavior is actually removed or changed incompatibly. When genuinely + unclear between two levels, pick the lower one (default to patch). + + A major bump of ${{ steps.last-release.outputs.stable_version }} increments + the first number and zeroes the rest (e.g. 0.1.5 -> 1.0.0); minor + increments the second (0.1.5 -> 0.2.0); patch the third (0.1.5 -> 0.1.6). + For prereleases use the same logic — the -rc suffix is added automatically. + + === CHANGES SINCE ${{ steps.last-release.outputs.stable_version }} === + ${{ steps.context.outputs.context }} + === END CHANGES === + Respond with ONLY X.Y.Z - name: Parse Version