Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

[Description of the pull request]

## Release notes

<!--
If this PR should publish a version, add a release:patch, release:minor or release:major label and write here, in English, what SDK users will see.
If left empty, GitHub's auto-generated notes are used. PRs without a release label don't publish.
-->

## Requirements

[Additional actions that have to be done for the pull request to work (such as adding a secret to the repository)]
Expand Down
89 changes: 89 additions & 0 deletions .github/scripts/resolve-release.sh
Original file line number Diff line number Diff line change
@@ -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
83 changes: 83 additions & 0 deletions .github/scripts/resolve-release.test.sh
Original file line number Diff line number Diff line change
@@ -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<!-- instructions\nspanning lines -->\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<!-- empty -->\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; }
85 changes: 81 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 != ''
Comment thread
pibahamondesw marked this conversation as resolved.
Resolved
runs-on: blacksmith-2vcpu-ubuntu-2404
concurrency:
group: release
Comment thread
pibahamondesw marked this conversation as resolved.
Resolved
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
Expand All @@ -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

Expand All @@ -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 }}
10 changes: 10 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 11 additions & 4 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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` |
Loading