diff --git a/.github/workflows/test-release-github.yml b/.github/workflows/test-release-github.yml index 00f4ff4..c138f1f 100644 --- a/.github/workflows/test-release-github.yml +++ b/.github/workflows/test-release-github.yml @@ -1117,7 +1117,20 @@ jobs: echo "=== Running recovery path ===" # Read changelog_file from commitizen config (same as action does) - CHANGELOG_FILE=$(grep -A 20 "^\[tool\.commitizen\]" pyproject.toml | grep "^changelog_file" | head -1 | sed 's/.*=\s*"\(.*\)"/\1/' || true) + CHANGELOG_FILE=$(awk -v q="'" ' + /^[[:space:]]*\[tool\.commitizen\][[:space:]]*(#.*)?$/ { inblock = 1; next } + inblock && /^[[:space:]]*\[/ { exit } + inblock && /^[[:space:]]*changelog_file[[:space:]]*=/ { + rhs = $0 + sub(/^[^=]*=[[:space:]]*/, "", rhs) # rhs now starts at the value + qc = substr(rhs, 1, 1) # opening quote char of the value + if (qc == "\"" || qc == q) { # anchor to the value quote, not any quote in a comment + rest = substr(rhs, 2); p = index(rest, qc) + if (p > 0) { print substr(rest, 1, p - 1); exit } + } + exit + } + ' pyproject.toml || true) if [ -z "$CHANGELOG_FILE" ]; then CHANGELOG_FILE="CHANGELOG.md" fi @@ -1287,7 +1300,20 @@ jobs: uv run cz bump --yes --increment minor # Read changelog file path - CHANGELOG_FILE=$(grep -A 20 "^\[tool\.commitizen\]" pyproject.toml | grep "^changelog_file" | head -1 | sed 's/.*=\s*"\(.*\)"/\1/' || true) + CHANGELOG_FILE=$(awk -v q="'" ' + /^[[:space:]]*\[tool\.commitizen\][[:space:]]*(#.*)?$/ { inblock = 1; next } + inblock && /^[[:space:]]*\[/ { exit } + inblock && /^[[:space:]]*changelog_file[[:space:]]*=/ { + rhs = $0 + sub(/^[^=]*=[[:space:]]*/, "", rhs) # rhs now starts at the value + qc = substr(rhs, 1, 1) # opening quote char of the value + if (qc == "\"" || qc == q) { # anchor to the value quote, not any quote in a comment + rest = substr(rhs, 2); p = index(rest, qc) + if (p > 0) { print substr(rest, 1, p - 1); exit } + } + exit + } + ' pyproject.toml || true) if [ -z "$CHANGELOG_FILE" ]; then CHANGELOG_FILE="CHANGELOG.md" fi @@ -1414,7 +1440,20 @@ jobs: - name: Verify release notes extraction from custom path run: | - CHANGELOG_FILE=$(grep -A 20 "^\[tool\.commitizen\]" pyproject.toml | grep "^changelog_file" | head -1 | sed 's/.*=\s*"\(.*\)"/\1/' || true) + CHANGELOG_FILE=$(awk -v q="'" ' + /^[[:space:]]*\[tool\.commitizen\][[:space:]]*(#.*)?$/ { inblock = 1; next } + inblock && /^[[:space:]]*\[/ { exit } + inblock && /^[[:space:]]*changelog_file[[:space:]]*=/ { + rhs = $0 + sub(/^[^=]*=[[:space:]]*/, "", rhs) # rhs now starts at the value + qc = substr(rhs, 1, 1) # opening quote char of the value + if (qc == "\"" || qc == q) { # anchor to the value quote, not any quote in a comment + rest = substr(rhs, 2); p = index(rest, qc) + if (p > 0) { print substr(rest, 1, p - 1); exit } + } + exit + } + ' pyproject.toml || true) if [ -z "$CHANGELOG_FILE" ]; then CHANGELOG_FILE="CHANGELOG.md" fi @@ -1963,3 +2002,121 @@ jobs: fi done echo "[OK] pip: no lockfile created; fold step correctly skipped" + + # Regression test: changelog_file must be found no matter how far below the + # [tool.commitizen] header it sits. The old detection used a fixed + # `grep -A 20` window, so a config with a long version_files array or comment + # block before changelog_file (as in the earthlens uv workspace) fell through + # to the CHANGELOG.md default and failed with "Changelog file not found". + # This fixture places changelog_file ~30 lines below the header on purpose. + test-release-changelog-far-from-header: + name: Test release - changelog_file far below the commitizen header + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Copy test fixture + run: | + cp tests/data/release-github/test-basic-uv/pyproject.toml . + cp tests/data/release-github/test-basic-uv/uv.lock . + + - name: Rewrite commitizen config so changelog_file is far below the header + run: | + # Remove ONLY the existing [tool.commitizen] section (header + body up to + # the next table) so this does not depend on it being the last section of + # the shared fixture. Then append a version that pushes changelog_file well + # past 20 lines below the header (reproducing the earthlens layout). + awk ' + /^\[tool\.commitizen\]/ { skip = 1; next } + skip && /^\[/ { skip = 0 } + !skip { print } + ' pyproject.toml > pyproject.toml.tmp && mv pyproject.toml.tmp pyproject.toml + { + echo '[tool.commitizen]' + echo 'name = "cz_conventional_commits"' + echo 'version = "0.1.0"' + echo 'tag_format = "$version"' + echo 'update_changelog_on_bump = true' + echo 'version_files = [' + echo ' "pyproject.toml:version"' + echo ']' + for i in $(seq 1 25); do + echo "# padding line $i pushing changelog_file past the old grep -A 20 window" + done + echo 'changelog_file = "docs/change-log.md"' + } >> pyproject.toml + + mkdir -p docs + echo "# Changelog" > docs/change-log.md + + HEADER=$(grep -n '^\[tool\.commitizen\]' pyproject.toml | tail -1 | cut -d: -f1) + CLINE=$(grep -n '^changelog_file' pyproject.toml | tail -1 | cut -d: -f1) + echo "header at line $HEADER, changelog_file at line $CLINE (delta $((CLINE - HEADER)))" + if [ $((CLINE - HEADER)) -le 20 ]; then + echo "::error::test setup invalid: changelog_file is not far enough below the header" + exit 1 + fi + echo "[OK] changelog_file sits $((CLINE - HEADER)) lines below the header (> 20)" + + - name: Clean up repository tags + run: git tag -l | xargs -r git tag -d + + - name: Setup fake git remote for testing + uses: ./.github/workflows/test-helpers/setup-fake-remote + + - name: Normalize local branch to main (dispatch-testability) + shell: bash + run: | + git checkout -B main + git config push.default current + + - name: Create initial commit with a prior tag + run: | + git config user.name "Test Bot" + git config user.email "test@example.com" + + git add pyproject.toml uv.lock docs/change-log.md + git commit -m "chore: initial commit" + git tag 0.1.0 + git push origin main + git push origin 0.1.0 + + echo "feature" > feature.py + git add feature.py + git commit -m "feat: trigger a minor bump" + git push origin main + + - name: Run release action + uses: ./actions/release/github + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + increment: 'minor' + prerelease-type: 'none' + package-manager: 'uv' + python-version: '3.12' + install-groups: 'groups: dev docs' + skip-github-release: 'true' + + - name: Verify the release used the custom changelog, not the CHANGELOG.md default + run: | + # The action must have found docs/change-log.md. If detection had fallen + # back to CHANGELOG.md the validate step would have failed the job, and no + # CHANGELOG.md should exist. + if [ -f CHANGELOG.md ]; then + echo "::error::CHANGELOG.md exists — detection fell back to the default" + exit 1 + fi + if ! git rev-parse 0.2.0 >/dev/null 2>&1; then + echo "::error::tag 0.2.0 was not created" + git tag -l + exit 1 + fi + if ! grep -q "0.2.0" docs/change-log.md; then + echo "::error::docs/change-log.md was not updated with the 0.2.0 entry" + cat docs/change-log.md + exit 1 + fi + echo "[OK] release found changelog_file far below the header; docs/change-log.md updated" diff --git a/actions/release/github/action.yml b/actions/release/github/action.yml index 5da34d7..25d24a9 100644 --- a/actions/release/github/action.yml +++ b/actions/release/github/action.yml @@ -240,7 +240,30 @@ runs: CHANGELOG_FILE="" if [ -f "pyproject.toml" ]; then - CHANGELOG_FILE=$(grep -A 20 "^\[tool\.commitizen\]" pyproject.toml | grep "^changelog_file" | head -1 | sed 's/.*=\s*"\(.*\)"/\1/' || true) + # Read changelog_file from the [tool.commitizen] section, section-aware: + # scan from the header to the next "[...]" table so the key is found no + # matter how far below the header it sits (e.g. after a long version_files + # array). Tolerates a trailing comment on the header and accepts a double- + # or single-quoted value, anchoring extraction to the value's own opening + # quote so an inline "# comment" (even one containing quotes) is ignored. + # Empty result falls back to CHANGELOG.md below. `q` carries a literal + # single quote so the awk program needs no embedded single quote. Assumes + # the ordinary commitizen layout: scalar keys, no multi-line string or + # nested-array key that starts a line with "[" ahead of changelog_file. + CHANGELOG_FILE=$(awk -v q="'" ' + /^[[:space:]]*\[tool\.commitizen\][[:space:]]*(#.*)?$/ { inblock = 1; next } + inblock && /^[[:space:]]*\[/ { exit } + inblock && /^[[:space:]]*changelog_file[[:space:]]*=/ { + rhs = $0 + sub(/^[^=]*=[[:space:]]*/, "", rhs) # rhs now starts at the value + qc = substr(rhs, 1, 1) # opening quote char of the value + if (qc == "\"" || qc == q) { # anchor to the value quote, not any quote in a comment + rest = substr(rhs, 2); p = index(rest, qc) + if (p > 0) { print substr(rest, 1, p - 1); exit } + } + exit + } + ' pyproject.toml || true) fi # Default to CHANGELOG.md if not found @@ -303,7 +326,30 @@ runs: # Read changelog_file from commitizen config (needed for recovery path) CHANGELOG_FILE="" if [ -f "pyproject.toml" ]; then - CHANGELOG_FILE=$(grep -A 20 "^\[tool\.commitizen\]" pyproject.toml | grep "^changelog_file" | head -1 | sed 's/.*=\s*"\(.*\)"/\1/' || true) + # Read changelog_file from the [tool.commitizen] section, section-aware: + # scan from the header to the next "[...]" table so the key is found no + # matter how far below the header it sits (e.g. after a long version_files + # array). Tolerates a trailing comment on the header and accepts a double- + # or single-quoted value, anchoring extraction to the value's own opening + # quote so an inline "# comment" (even one containing quotes) is ignored. + # Empty result falls back to CHANGELOG.md below. `q` carries a literal + # single quote so the awk program needs no embedded single quote. Assumes + # the ordinary commitizen layout: scalar keys, no multi-line string or + # nested-array key that starts a line with "[" ahead of changelog_file. + CHANGELOG_FILE=$(awk -v q="'" ' + /^[[:space:]]*\[tool\.commitizen\][[:space:]]*(#.*)?$/ { inblock = 1; next } + inblock && /^[[:space:]]*\[/ { exit } + inblock && /^[[:space:]]*changelog_file[[:space:]]*=/ { + rhs = $0 + sub(/^[^=]*=[[:space:]]*/, "", rhs) # rhs now starts at the value + qc = substr(rhs, 1, 1) # opening quote char of the value + if (qc == "\"" || qc == q) { # anchor to the value quote, not any quote in a comment + rest = substr(rhs, 2); p = index(rest, qc) + if (p > 0) { print substr(rest, 1, p - 1); exit } + } + exit + } + ' pyproject.toml || true) fi if [ -z "$CHANGELOG_FILE" ]; then CHANGELOG_FILE="CHANGELOG.md"