From 88bc7844e34bdee0d2f03f432199f4ea7cad13b6 Mon Sep 17 00:00:00 2001 From: NikoKS Date: Wed, 26 Aug 2026 22:59:40 +0700 Subject: [PATCH] intial commit --- .gitea/workflows/test.yaml | 18 ++++++ LICENSE | 21 +++++++ README.md | 112 ++++++++++++++++++++++++++++++++++++ action.yml | 77 +++++++++++++++++++++++++ scripts/bump.sh | 114 +++++++++++++++++++++++++++++++++++++ scripts/read_version.py | 44 ++++++++++++++ tests/integration.sh | 60 +++++++++++++++++++ 7 files changed, 446 insertions(+) create mode 100644 .gitea/workflows/test.yaml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 action.yml create mode 100755 scripts/bump.sh create mode 100755 scripts/read_version.py create mode 100755 tests/integration.sh diff --git a/.gitea/workflows/test.yaml b/.gitea/workflows/test.yaml new file mode 100644 index 0000000..c7aab1d --- /dev/null +++ b/.gitea/workflows/test.yaml @@ -0,0 +1,18 @@ +name: Test action + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + integration: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4 + + - name: Run integration test + run: ./tests/integration.sh diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..940f83b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Nurinda + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ae40c63 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# Bump ERP App Version + +A forge-neutral composite action for preparing a versioned Frappe/ERPNext app before merging its release branch. It uses [`bump-my-version`](https://callowayproject.github.io/bump-my-version/) and ordinary Git commands, without `gh` or GitHub API calls. + +The action compares `[tool.bumpversion].current_version` on the checked-out target branch with the configured base branch: + +- Equal: bump, commit, and optionally push the new version. +- Target newer than base: do nothing, making PR synchronization events idempotent. +- Target older than base: fail and require branch synchronization. + +## Requirements + +- A Linux runner with Bash, Git, and Python 3.11 or newer. +- A clean checkout of the branch that should receive the version commit. +- Persisted Git credentials with permission to push that branch. +- Stable `MAJOR.MINOR.PATCH` versions. +- A `bump-my-version` configuration that includes every version file. + +Example `pyproject.toml` for a Frappe app: + +```toml +[tool.bumpversion] +current_version = "0.0.1" +commit = false +tag = false + +[[tool.bumpversion.files]] +filename = "erp_operations/__init__.py" +search = '__version__ = "{current_version}"' +replace = '__version__ = "{new_version}"' +``` + +## Gitea Actions usage + +```yaml +name: Prepare application version + +on: + pull_request: + branches: [main] + types: [opened, reopened, synchronize] + +permissions: + contents: write + pull-requests: read + +jobs: + version: + if: ${{ gitea.head_ref == 'develop' }} + runs-on: ubuntu-latest + steps: + - name: Check out develop + uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4 + with: + ref: develop + fetch-depth: 0 + token: ${{ secrets.GITEA_TOKEN }} + + - name: Prepare version + id: version + uses: Nurinda/bump-erp-app-version@v1 + with: + config-file: pyproject.toml + base-branch: main + target-branch: develop + bump: patch + git-user-name: ERP Operations Release Bot + git-user-email: it@nurinda.co.id + + - name: Report version + run: echo "${{ steps.version.outputs.previous-version }} -> ${{ steps.version.outputs.current-version }}" +``` + +Gitea may suppress workflows caused by pushes authenticated with its built-in `GITEA_TOKEN`. If CI checks must rerun on the generated commit, pass a narrowly scoped bot repository token to `actions/checkout` instead. + +## Inputs + +| Input | Default | Description | +|---|---|---| +| `config-file` | `pyproject.toml` | `bump-my-version` TOML configuration | +| `base-branch` | `main` | Branch providing the baseline version | +| `target-branch` | `develop` | Checked-out branch receiving the commit | +| `bump` | `patch` | `major`, `minor`, or `patch` | +| `remote` | `origin` | Git remote used for fetch and push | +| `commit-message` | `chore(release): prepare v{version}` | Version commit message | +| `git-user-name` | `ERP App Release Bot` | Git commit identity | +| `git-user-email` | `actions@localhost` | Git commit identity | +| `bump-my-version-version` | `1.5.1` | Exact package version installed in an isolated environment | +| `push` | `true` | Whether to push the created commit | + +## Outputs + +| Output | Description | +|---|---| +| `bumped` | `true` when a commit was created | +| `previous-version` | Version before the action ran | +| `current-version` | Version after the action ran | +| `commit` | Created commit SHA, empty for a no-op | + +## Safety behavior + +The action does not force-push, create tags, publish releases, or call forge APIs. Concurrent release jobs are resolved safely by the remote rejecting a non-fast-forward push. Configure workflow-level concurrency if release PRs can overlap. + +## Development + +Run the integration test: + +```bash +./tests/integration.sh +``` + +The test creates a temporary Git remote and verifies bumping, committing, pushing, and idempotent reruns. diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..6e7f2a5 --- /dev/null +++ b/action.yml @@ -0,0 +1,77 @@ +name: Bump ERP app version +description: Bump a Frappe/ERPNext app version on a release branch using bump-my-version +inputs: + config-file: + description: Path to the bump-my-version TOML configuration + required: false + default: pyproject.toml + base-branch: + description: Branch whose version is used as the release baseline + required: false + default: main + target-branch: + description: Checked-out branch that receives the version commit + required: false + default: develop + bump: + description: Version component passed to bump-my-version + required: false + default: patch + remote: + description: Git remote used to fetch and push + required: false + default: origin + commit-message: + description: Commit message; {version} is replaced with the new version + required: false + default: "chore(release): prepare v{version}" + git-user-name: + description: Author and committer name for the version commit + required: false + default: ERP App Release Bot + git-user-email: + description: Author and committer email for the version commit + required: false + default: actions@localhost + bump-my-version-version: + description: Pinned bump-my-version package version to install + required: false + default: "1.5.1" + push: + description: Push the version commit to target-branch + required: false + default: "true" +outputs: + bumped: + description: Whether this run created a version commit + value: ${{ steps.bump.outputs.bumped }} + previous-version: + description: Version before this action ran + value: ${{ steps.bump.outputs.previous-version }} + current-version: + description: Version after this action ran + value: ${{ steps.bump.outputs.current-version }} + commit: + description: Created commit SHA, or an empty string when no bump was needed + value: ${{ steps.bump.outputs.commit }} +runs: + using: composite + steps: + - name: Compare, bump, commit, and push + id: bump + shell: bash + env: + INPUT_CONFIG_FILE: ${{ inputs.config-file }} + INPUT_BASE_BRANCH: ${{ inputs.base-branch }} + INPUT_TARGET_BRANCH: ${{ inputs.target-branch }} + INPUT_BUMP: ${{ inputs.bump }} + INPUT_REMOTE: ${{ inputs.remote }} + INPUT_COMMIT_MESSAGE: ${{ inputs.commit-message }} + INPUT_GIT_USER_NAME: ${{ inputs.git-user-name }} + INPUT_GIT_USER_EMAIL: ${{ inputs.git-user-email }} + INPUT_BUMP_MY_VERSION_VERSION: ${{ inputs.bump-my-version-version }} + INPUT_PUSH: ${{ inputs.push }} + run: ${{ github.action_path }}/scripts/bump.sh +branding: + icon: chevrons-up + color: blue diff --git a/scripts/bump.sh b/scripts/bump.sh new file mode 100755 index 0000000..ea26436 --- /dev/null +++ b/scripts/bump.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${INPUT_CONFIG_FILE:?config-file is required}" +: "${INPUT_BASE_BRANCH:?base-branch is required}" +: "${INPUT_TARGET_BRANCH:?target-branch is required}" +: "${INPUT_BUMP:?bump is required}" +: "${INPUT_REMOTE:?remote is required}" +: "${INPUT_COMMIT_MESSAGE:?commit-message is required}" +: "${INPUT_GIT_USER_NAME:?git-user-name is required}" +: "${INPUT_GIT_USER_EMAIL:?git-user-email is required}" +: "${INPUT_BUMP_MY_VERSION_VERSION:?bump-my-version-version is required}" +: "${INPUT_PUSH:?push is required}" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" + +case "${INPUT_PUSH}" in + true | false) ;; + *) echo "push must be 'true' or 'false'" >&2; exit 1 ;; +esac + +case "${INPUT_BUMP}" in + major | minor | patch) ;; + *) echo "bump must be major, minor, or patch" >&2; exit 1 ;; +esac + +if [[ ! -f "${INPUT_CONFIG_FILE}" ]]; then + echo "Configuration file not found: ${INPUT_CONFIG_FILE}" >&2 + exit 1 +fi + +if [[ -n "$(git status --porcelain)" ]]; then + echo "Working tree must be clean before bumping the version" >&2 + exit 1 +fi + +current_branch=$(git branch --show-current) +if [[ "${current_branch}" != "${INPUT_TARGET_BRANCH}" ]]; then + echo "Expected checked-out branch '${INPUT_TARGET_BRANCH}', found '${current_branch:-detached HEAD}'" >&2 + exit 1 +fi + +git fetch "${INPUT_REMOTE}" \ + "${INPUT_BASE_BRANCH}:refs/remotes/${INPUT_REMOTE}/${INPUT_BASE_BRANCH}" + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +base_ref="refs/remotes/${INPUT_REMOTE}/${INPUT_BASE_BRANCH}" +current_version=$(python3 "${script_dir}/read_version.py" "${INPUT_CONFIG_FILE}") +base_version=$(python3 "${script_dir}/read_version.py" "${INPUT_CONFIG_FILE}" --ref "${base_ref}") + +comparison=$(python3 - "${current_version}" "${base_version}" <<'PY' +import re +import sys + +pattern = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") +versions = [] +for value in sys.argv[1:]: + match = pattern.fullmatch(value) + if not match: + raise SystemExit(f"Version must use stable MAJOR.MINOR.PATCH format: {value}") + versions.append(tuple(map(int, match.groups()))) + +print((versions[0] > versions[1]) - (versions[0] < versions[1])) +PY +) + +if [[ "${comparison}" == "1" ]]; then + echo "Version ${current_version} is already prepared (base: ${base_version})" + { + echo "bumped=false" + echo "previous-version=${current_version}" + echo "current-version=${current_version}" + echo "commit=" + } >> "${GITHUB_OUTPUT}" + exit 0 +fi + +if [[ "${comparison}" == "-1" ]]; then + echo "Target branch version ${current_version} is older than base branch version ${base_version}" >&2 + echo "Synchronize ${INPUT_TARGET_BRANCH} with ${INPUT_BASE_BRANCH} before releasing" >&2 + exit 1 +fi + +venv_dir=$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/bump-erp-app-version.XXXXXX") +trap 'rm -rf "${venv_dir}"' EXIT +python3 -m venv "${venv_dir}" +"${venv_dir}/bin/pip" install --disable-pip-version-check \ + "bump-my-version==${INPUT_BUMP_MY_VERSION_VERSION}" + +git config user.name "${INPUT_GIT_USER_NAME}" +git config user.email "${INPUT_GIT_USER_EMAIL}" + +message=${INPUT_COMMIT_MESSAGE//\{version\}/\{new_version\}} +"${venv_dir}/bin/bump-my-version" bump \ + --config-file "${INPUT_CONFIG_FILE}" \ + --commit \ + --no-tag \ + --message "${message}" \ + "${INPUT_BUMP}" + +new_version=$(python3 "${script_dir}/read_version.py" "${INPUT_CONFIG_FILE}") +commit=$(git rev-parse HEAD) + +if [[ "${INPUT_PUSH}" == "true" ]]; then + git push "${INPUT_REMOTE}" "HEAD:${INPUT_TARGET_BRANCH}" +fi + +{ + echo "bumped=true" + echo "previous-version=${current_version}" + echo "current-version=${new_version}" + echo "commit=${commit}" +} >> "${GITHUB_OUTPUT}" + +echo "Prepared ${current_version} -> ${new_version} (${commit})" diff --git a/scripts/read_version.py b/scripts/read_version.py new file mode 100755 index 0000000..6abc7b3 --- /dev/null +++ b/scripts/read_version.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Read bump-my-version's current_version from a local file or Git ref.""" + +from __future__ import annotations + +import argparse +import subprocess +import tomllib + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("config_file") + parser.add_argument("--ref") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + try: + if args.ref: + content = subprocess.check_output( + ["git", "show", f"{args.ref}:{args.config_file}"], + ) + else: + with open(args.config_file, "rb") as config_file: + content = config_file.read() + except (OSError, subprocess.CalledProcessError) as error: + raise SystemExit(f"Could not load {args.config_file}: {error}") from error + + try: + version = tomllib.loads(content.decode())["tool"]["bumpversion"]["current_version"] + except (KeyError, TypeError, UnicodeDecodeError, tomllib.TOMLDecodeError) as error: + raise SystemExit( + f"Could not read tool.bumpversion.current_version from {args.config_file}: {error}" + ) from error + + if not isinstance(version, str): + raise SystemExit("tool.bumpversion.current_version must be a string") + print(version) + + +if __name__ == "__main__": + main() diff --git a/tests/integration.sh b/tests/integration.sh new file mode 100755 index 0000000..8915c7e --- /dev/null +++ b/tests/integration.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +test_root=$(mktemp -d) +trap 'rm -rf "${test_root}"' EXIT + +git init --bare -q "${test_root}/remote.git" +git init -q -b main "${test_root}/app" +cd "${test_root}/app" +git config user.name Test +git config user.email test@example.com +mkdir erp_operations +cat > pyproject.toml <<'TOML' +[tool.bumpversion] +current_version = "0.0.1" +commit = false +tag = false + +[[tool.bumpversion.files]] +filename = "erp_operations/__init__.py" +search = '__version__ = "{current_version}"' +replace = '__version__ = "{new_version}"' +TOML +printf '%s\n' '__version__ = "0.0.1"' > erp_operations/__init__.py +git add . +git commit -qm initial +git remote add origin "${test_root}/remote.git" +git push -qu origin main +git switch -qc develop +git push -qu origin develop + +export INPUT_CONFIG_FILE=pyproject.toml +export INPUT_BASE_BRANCH=main +export INPUT_TARGET_BRANCH=develop +export INPUT_BUMP=patch +export INPUT_REMOTE=origin +export INPUT_COMMIT_MESSAGE='chore(release): prepare v{version}' +export INPUT_GIT_USER_NAME='ERP App Release Bot' +export INPUT_GIT_USER_EMAIL='actions@example.com' +export INPUT_BUMP_MY_VERSION_VERSION=1.5.1 +export INPUT_PUSH=true +export RUNNER_TEMP="${test_root}" +export GITHUB_OUTPUT="${test_root}/output" + +"${repo_root}/scripts/bump.sh" +grep -q '^current_version = "0.0.2"$' pyproject.toml +grep -q '^__version__ = "0.0.2"$' erp_operations/__init__.py +grep -q '^bumped=true$' "${GITHUB_OUTPUT}" +grep -q '^previous-version=0.0.1$' "${GITHUB_OUTPUT}" +grep -q '^current-version=0.0.2$' "${GITHUB_OUTPUT}" +[[ "$(git log -1 --format=%s)" == "chore(release): prepare v0.0.2" ]] +[[ "$(git rev-parse HEAD)" == "$(git --git-dir="${test_root}/remote.git" rev-parse develop)" ]] + +: > "${GITHUB_OUTPUT}" +"${repo_root}/scripts/bump.sh" +grep -q '^bumped=false$' "${GITHUB_OUTPUT}" +[[ "$(git rev-list --count main..develop)" == "1" ]] + +echo "Integration test passed"