diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 46f4047..f870d26 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,6 +2,13 @@ [Description of the pull request] +## Release notes + + + ## Requirements [Additional actions that have to be done for the pull request to work (such as adding a secret to the repository)] diff --git a/.github/scripts/resolve-release.sh b/.github/scripts/resolve-release.sh new file mode 100755 index 0000000..bddd1d5 --- /dev/null +++ b/.github/scripts/resolve-release.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Decide the bump and release notes for the release workflow. +# Writes `bump` (empty means skip the release) and `notes` to $GITHUB_OUTPUT. +# +# Required env: EVENT_NAME, GITHUB_OUTPUT +# workflow_dispatch: INPUT_BUMP, INPUT_NOTES +# push: SHA, PULLS_JSON (response of GET repos/{repo}/commits/{sha}/pulls) +set -euo pipefail + +extract_release_notes() { + perl -0777 -ne ' + s/\r//g; + s///gs; + if (/^##[ \t]+Release notes[ \t]*$(.*?)(?=^##[ \t]|\z)/msi) { + my $notes = $1; + $notes =~ s/^\s+|\s+$//g; + print $notes; + } + ' +} + +trim() { + perl -0777 -pe 's/^\s+|\s+$//g' +} + +write_output() { + local bump=$1 notes=$2 delimiter + delimiter="RELEASE_NOTES_$(openssl rand -hex 16)" + { + echo "bump=$bump" + echo "notes<<$delimiter" + printf '%s\n' "$notes" + echo "$delimiter" + } >> "$GITHUB_OUTPUT" +} + +skip_release() { + echo "::notice::$1 Skipping release." + write_output "" "" +} + +resolve_from_dispatch() { + write_output "$INPUT_BUMP" "$(printf '%s' "${INPUT_NOTES:-}" | trim)" +} + +resolve_from_push() { + local pr number labels label_count bump + pr=$(jq -c --arg sha "$SHA" \ + 'map(select(.merged_at != null and .merge_commit_sha == $sha)) | first // empty' \ + <<< "$PULLS_JSON") + if [ -z "$pr" ]; then + skip_release "No merged PR found for $SHA." + return + fi + + number=$(jq -r '.number' <<< "$pr") + labels=$(jq -r '[.labels[].name | select(startswith("release:"))] | join(" ")' <<< "$pr") + label_count=$(jq '[.labels[].name | select(startswith("release:"))] | length' <<< "$pr") + + if [ "$label_count" -eq 0 ]; then + skip_release "PR #$number has no release:* label." + return + fi + if [ "$label_count" -gt 1 ]; then + echo "::error::PR #$number has more than one release:* label ($labels). Keep only one." + exit 1 + fi + + bump=${labels#release:} + case "$bump" in + patch|minor|major) ;; + *) + echo "::error::PR #$number has an unknown label $labels. Use release:patch, release:minor or release:major." + exit 1 + ;; + esac + + echo "Releasing PR #$number with bump $bump." + write_output "$bump" "$(jq -r '.body // ""' <<< "$pr" | extract_release_notes)" +} + +case "$EVENT_NAME" in + workflow_dispatch) resolve_from_dispatch ;; + push) resolve_from_push ;; + *) + echo "::error::Unsupported event $EVENT_NAME." + exit 1 + ;; +esac diff --git a/.github/scripts/resolve-release.test.sh b/.github/scripts/resolve-release.test.sh new file mode 100755 index 0000000..d4ece65 --- /dev/null +++ b/.github/scripts/resolve-release.test.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -uo pipefail + +SCRIPT="$(dirname "$0")/resolve-release.sh" +SHA=abc123 +failures=0 + +pulls_json() { + local labels=$1 body=$2 + jq -nc --arg sha "$SHA" --arg body "$body" --argjson labels "$labels" \ + '[{number: 7, merged_at: "2026-01-01T00:00:00Z", merge_commit_sha: $sha, + body: $body, labels: ($labels | map({name: .}))}]' +} + +read_output() { + local file=$1 name=$2 + awk -v name="$name" ' + index($0, name "=") == 1 { print substr($0, length(name) + 2); exit } + index($0, name "<<") == 1 { delimiter = substr($0, length(name) + 3); reading = 1; next } + reading && $0 == delimiter { exit } + reading { print } + ' "$file" +} + +assert_release() { + local description=$1 expected_status=$2 expected_bump=$3 expected_notes=$4 + shift 4 + local output status + output=$(mktemp) + env GITHUB_OUTPUT="$output" "$@" bash "$SCRIPT" > /dev/null + status=$? + + if [ "$status" -ne "$expected_status" ]; then + echo "FAIL: $description (exit $status, expected $expected_status)" + failures=$((failures + 1)) + elif [ "$expected_status" -eq 0 ] && + { [ "$(read_output "$output" bump)" != "$expected_bump" ] || + [ "$(read_output "$output" notes)" != "$expected_notes" ]; }; then + echo "FAIL: $description" + echo " bump: '$(read_output "$output" bump)' expected '$expected_bump'" + echo " notes: '$(read_output "$output" notes)' expected '$expected_notes'" + failures=$((failures + 1)) + else + echo "ok: $description" + fi + rm -f "$output" +} + +assert_push() { + local description=$1 expected_status=$2 expected_bump=$3 expected_notes=$4 labels=$5 body=$6 + assert_release "$description" "$expected_status" "$expected_bump" "$expected_notes" \ + EVENT_NAME=push SHA="$SHA" PULLS_JSON="$(pulls_json "$labels" "$body")" +} + +assert_push "notes section at the end, comments stripped" 0 minor $'- Added X.\n- Removed Y.' \ + '["bug", "release:minor"]' \ + $'## Contexto\nctx\n\n## Release notes\n\n\n\n- Added X.\n- Removed Y.\n' + +assert_push "notes section followed by another section" 0 patch $'Fixed X.\n### Details\nmore' \ + '["release:patch"]' \ + $'## Release notes\r\n\r\nFixed X.\r\n### Details\r\nmore\r\n\r\n## Rollback\r\nsafe\r\n' + +assert_push "notes section with only a comment" 0 major "" \ + '["release:major"]' $'## Release notes\n\n## Rollback\nx' + +assert_push "no notes section" 0 patch "" '["release:patch"]' $'## Contexto\nctx' + +assert_push "PR without release label is skipped" 0 "" "" '["bug"]' $'## Release notes\nx' + +assert_push "more than one release label fails" 1 "" "" '["release:patch", "release:minor"]' "x" + +assert_push "unknown release label fails" 1 "" "" '["release:hotfix"]' "x" + +assert_release "push without merged PR is skipped" 0 "" "" \ + EVENT_NAME=push SHA="$SHA" PULLS_JSON='[{"number": 8, "merged_at": null, "merge_commit_sha": "abc123", "labels": [{"name": "release:patch"}], "body": ""}]' + +assert_release "workflow_dispatch uses the inputs" 0 minor "notes here" \ + EVENT_NAME=workflow_dispatch INPUT_BUMP=minor INPUT_NOTES=$' \n notes here\n' + +assert_release "workflow_dispatch with empty notes" 0 patch "" \ + EVENT_NAME=workflow_dispatch INPUT_BUMP=patch INPUT_NOTES="" + +[ "$failures" -eq 0 ] || { echo "$failures failing case(s)"; exit 1; } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39653ab..9e4df8b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,16 +13,75 @@ on: description: 'Release notes (markdown, opcional)' type: string required: false + push: + branches: [master] + +permissions: {} jobs: + resolve: + runs-on: blacksmith-2vcpu-ubuntu-2404 + permissions: + contents: read + pull-requests: read + outputs: + bump: ${{ steps.resolve.outputs.bump }} + notes: ${{ steps.resolve.outputs.notes }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Find merged PR + id: pulls + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ github.token }} + run: | + for attempt in 1 2 3; do + pulls=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" --jq 'tojson') + if [ "$pulls" != "[]" ] || [ "$attempt" -eq 3 ]; then break; fi + sleep 10 + done + echo "json=$pulls" >> "$GITHUB_OUTPUT" + + - name: Resolve bump and release notes + id: resolve + env: + EVENT_NAME: ${{ github.event_name }} + SHA: ${{ github.sha }} + INPUT_BUMP: ${{ inputs.bump }} + INPUT_NOTES: ${{ inputs.release-notes }} + PULLS_JSON: ${{ steps.pulls.outputs.json }} + run: .github/scripts/resolve-release.sh + release: - if: github.ref == 'refs/heads/master' - runs-on: ubuntu-latest + needs: resolve + if: github.ref == 'refs/heads/master' && needs.resolve.outputs.bump != '' + runs-on: blacksmith-2vcpu-ubuntu-2404 + concurrency: + group: release + cancel-in-progress: false permissions: contents: write steps: - name: Checkout to commit code uses: actions/checkout@v3 + with: + ref: master + + - name: Check master has no other changes after the merged PR + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ github.token }} + run: | + head=$(git rev-parse HEAD) + later=$(gh api "repos/$GITHUB_REPOSITORY/compare/$GITHUB_SHA...$head" \ + --jq '[.commits[] | select(.commit.message | startswith("release: ") | not) | .sha[0:7]] | join(" ")') + if [ -n "$later" ]; then + echo "::error::master has commits merged after $GITHUB_SHA ($later). Aborting so they are not published with this PR's bump and notes. The next release will include this PR; use workflow_dispatch if it needs a bigger bump." + exit 1 + fi - name: Set up Python uses: actions/setup-python@v4 @@ -48,12 +107,30 @@ jobs: - uses: fintoc-com/release-action/prepare@main id: prep with: - bump: ${{ inputs.bump }} + bump: ${{ needs.resolve.outputs.bump }} version-format: py tag-prefix: v app-id: ${{ vars.FIN_RELEASES_APP_ID }} app-private-key: ${{ secrets.FIN_RELEASES_PRIVATE_KEY }} + - name: Fall back to auto-generated release notes + id: notes + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.prep.outputs.tag }} + NOTES: ${{ needs.resolve.outputs.notes }} + run: | + if [ -z "${NOTES//[[:space:]]/}" ]; then + NOTES=$(gh api -X POST "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ + -f tag_name="$TAG" -f target_commitish="$GITHUB_SHA" --jq '.body') + fi + delimiter="RELEASE_NOTES_$(openssl rand -hex 16)" + { + echo "body<<$delimiter" + printf '%s\n' "$NOTES" + echo "$delimiter" + } >> "$GITHUB_OUTPUT" + - name: Build the package run: poetry build @@ -66,6 +143,6 @@ jobs: - uses: fintoc-com/release-action/finalize@main with: tag: ${{ steps.prep.outputs.tag }} - release-notes: ${{ inputs.release-notes }} + release-notes: ${{ steps.notes.outputs.body }} app-id: ${{ vars.FIN_RELEASES_APP_ID }} app-private-key: ${{ secrets.FIN_RELEASES_PRIVATE_KEY }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d6e8080..288acdb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,11 +8,21 @@ on: pull_request: paths: - ".github/workflows/tests.yml" + - ".github/scripts/**" - "Makefile" - "fintoc/**/*.py" - "tests/**/*.py" jobs: + release-scripts: + runs-on: ubuntu-latest + steps: + - name: Checkout to commit code + uses: actions/checkout@v4 + + - name: Test resolve-release + run: .github/scripts/resolve-release.test.sh + pytest: runs-on: ubuntu-latest steps: diff --git a/RELEASING.md b/RELEASING.md index 30bd232..9770ec8 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,12 +1,18 @@ # Releasing -Manual desde Actions. Toma ~2–3 min. +Automático al mergear a `master`. Toma ~2–3 min. ## Cómo -1. https://github.com/fintoc-com/fintoc-python/actions → **release** → **Run workflow**. -2. `bump`: `patch` / `minor` / `major`. `release-notes`: markdown opcional. -3. **Run workflow**. Solo corre desde `master`. +1. Agrega al PR un label `release:patch`, `release:minor` o `release:major`. +2. Escribe en la sección `## Release notes` de la descripción del PR lo que verán los usuarios del SDK, en inglés. Si queda vacía, se usan las notas autogeneradas de GitHub. +3. Mergea. Los PRs sin label `release:*` no publican; un PR con más de uno hace fallar el workflow. + +El release parte de `master` actualizado (incluye el commit de versión de un release anterior en cola). Si después del PR se mergeó algo más que un commit `release: ...`, aborta antes de publicar para no sacar esos cambios con el bump y las notas de este PR. El siguiente release los incluye; usa el manual si hace falta un bump mayor. + +### Manual + +https://github.com/fintoc-com/fintoc-python/actions → **release** → **Run workflow**, con `bump` (`patch` / `minor` / `major`) y `release-notes` opcionales. Solo corre desde `master`. ## Qué hace @@ -22,5 +28,6 @@ Todo autoreado por `fin-releases[bot]`. | Falla en | Estado | Recovery | |---|---|---| +| `master` tiene commits posteriores al PR | Nada publicado | Esperar el siguiente release, o correr el manual | | Antes o durante `pypi-publish` | Nada en el remote | Re-run | | `release/finalize` (post-publish) | Paquete en PyPI, sin tag/release | PR con el commit del bump + `gh release create vX.Y.Z` |