[no bump] Enable git hooks automatically on first bundle run
Publish plugin / publish (push) Successful in 27s

tools/bundle.py now sets `core.hooksPath scripts` the first time it runs in a
clone (skipped in CI, outside a git work tree, or when core.hooksPath is
already set), so the version auto-bump hooks work in fresh clones and repos
created from the template without a manual setup step. README documents the
hooks, the self-enabling behaviour and the bump stop words.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
lyuksovannyyandClaude Opus 5.5 committed 2026-09-23 12:56:23 +02:00
1 parent fc76a20420
commit b2105ca753
4 files changed
+99 -7

No files matched your search

+48 -6
View File
@@ -46,7 +46,7 @@ tools/
verify_bundle.py offline harness that proves a built bundle behaves correctly, no phone needed verify_bundle.py offline harness that proves a built bundle behaves correctly, no phone needed
release.py publishes a built bundle to a Gitea release (used by CI) release.py publishes a built bundle to a Gitea release (used by CI)
.gitea/workflows/ CI that builds, verifies and publishes the .plugin to Releases .gitea/workflows/ CI that builds, verifies and publishes the .plugin to Releases
scripts/ bash automation for the ADB debug loop (see below) scripts/ ADB debug-loop automation and the git hooks (see below)
docs/sdk-notes.md condensed SDK API surface gathered from the official docs docs/sdk-notes.md condensed SDK API surface gathered from the official docs
``` ```
@@ -65,6 +65,8 @@ a private import hook (scoped to a per-module `__builtins__` dict, never touchin
files were still on disk. `--dev` (the default) embeds absolute local paths so a debugger files were still on disk. `--dev` (the default) embeds absolute local paths so a debugger
resolves breakpoints against your real source files; `--release` embeds repo-relative paths resolves breakpoints against your real source files; `--release` embeds repo-relative paths
for distribution. Both modes produce byte-reproducible output for a given `src/` + `plugin.json`. for distribution. Both modes produce byte-reproducible output for a given `src/` + `plugin.json`.
Its first run in a clone also enables the repo's git hooks (see
[Git hooks and versioning](#git-hooks-and-versioning)).
## Device prerequisites ## Device prerequisites
@@ -97,6 +99,43 @@ standard `ANDROID_SERIAL` env var for device selection.
- `scripts/fetch-sdk-stubs.sh [--beta]` — downloads the latest SDK type stubs into `stubs/` - `scripts/fetch-sdk-stubs.sh [--beta]` — downloads the latest SDK type stubs into `stubs/`
for editor autocomplete. for editor autocomplete.
## Git hooks and versioning
`scripts/pre-commit` bumps the patch number of `version` in `plugin.json` on every commit
(`1.0.0` → `1.0.1`) and stages it into that same commit. `scripts/post-commit` re-stages
`plugin.json` afterwards, because commits of a file selection (`git commit -- <paths>`, which
some IDEs use) are built from a temporary index and would otherwise leave it looking modified.
**The hooks enable themselves.** Git never activates hooks from repository content, so a
fresh clone, or a repo created from this template, starts without them. The first run of
`tools/bundle.py` fixes that by running `git config core.hooksPath scripts`, and every
workflow runs it: directly, or through `scripts/deploy.sh` and `scripts/watch.sh`. That
includes `--check`, so after cloning run
```bash
python3 tools/bundle.py --check
```
once, before your first commit. A commit made before any build runs without the hooks.
`bundle.py` leaves `core.hooksPath` alone when it is already set (so pointing it elsewhere on
purpose sticks), skips the step in CI (whenever `CI` is set) and outside a git work tree, and
never fails a build over it. To turn the hooks off for a clone, run
`git config core.hooksPath .git/hooks`; `git config --unset core.hooksPath` only lasts
until the next build.
The bump is skipped when:
- the commit message contains `[no bump]`, `[skip bump]`, `[no version bump]` or
`[skip version]` (case-insensitive; `nobump`, `no-bump` and `no_bump` also work),
- `NO_BUMP=1` (or `GIT_NO_BUMP=1`) is set, e.g. `NO_BUMP=1 git commit`, which is the only way
for commits written in an editor, since their message doesn't exist yet when the hook runs,
- `version` in `plugin.json` already differs from `HEAD` (you bumped it by hand, e.g. for a
minor or major release),
- a merge, rebase, cherry-pick or revert is in progress.
Because every version change publishes a release (see below), a normal commit + push to
`main` ships a new patch release. Use a stop word for commits that should not.
## Publishing a release ## Publishing a release
`.gitea/workflows/release.yml` builds, verifies and publishes the `.plugin` to the repo's `.gitea/workflows/release.yml` builds, verifies and publishes the `.plugin` to the repo's
@@ -111,7 +150,8 @@ The `version` field in `plugin.json` is the single source of truth:
| the same `version` | rebuilds the existing release, replacing its attachment | | the same `version` | rebuilds the existing release, replacing its attachment |
So every push leaves a downloadable build attached to the current version, and bumping So every push leaves a downloadable build attached to the current version, and bumping
`version` is what cuts a new release. Pushing a `v*` tag by hand still works and uses that `version` is what cuts a new release. With the git hooks enabled every commit bumps it,
so every push of a normal commit cuts a new patch release. Pushing a `v*` tag by hand still works and uses that
tag verbatim, as does a manual run (Actions → Publish plugin → Run workflow). tag verbatim, as does a manual run (Actions → Publish plugin → Run workflow).
Every path runs `--check`, builds with `--release`, and gates the upload on Every path runs `--check`, builds with `--release`, and gates the upload on
@@ -143,9 +183,11 @@ you want when publishing needs to reach beyond this repo.
## Writing your own plugin ## Writing your own plugin
1. Edit `plugin.json` (id, name, description, etc.). 1. Run `python3 tools/bundle.py --check` once. It enables the git hooks that auto-bump the
2. Write your plugin under `src/`, with `src/main.py` as the entry module (absolute imports version on commit (see [Git hooks and versioning](#git-hooks-and-versioning)).
2. Edit `plugin.json` (id, name, description, etc.).
3. Write your plugin under `src/`, with `src/main.py` as the entry module (absolute imports
only — it's executed at plugin-module top level, not inside a package). only — it's executed at plugin-module top level, not inside a package).
3. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/<id>.plugin` 4. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/<id>.plugin`
(substitute your `id` from `plugin.json`). (substitute your `id` from `plugin.json`).
4. `bash scripts/deploy.sh --enable` with a device connected. 5. `bash scripts/deploy.sh --enable` with a device connected.
+2 -1
View File
@@ -1,5 +1,6 @@
#!/bin/sh #!/bin/sh
# Companion to scripts/pre-commit. # Companion to scripts/pre-commit. Both are enabled per clone by tools/bundle.py
# (it sets `core.hooksPath scripts` on its first run).
# #
# `git commit -- <pathspec>` (also how some IDEs commit a file selection) builds # `git commit -- <pathspec>` (also how some IDEs commit a file selection) builds
# the commit from a temporary index. The pre-commit bump therefore lands in the # the commit from a temporary index. The pre-commit bump therefore lands in the
+3
View File
@@ -1,6 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Increment the patch component of plugin.json's version as part of the commit. """Increment the patch component of plugin.json's version as part of the commit.
Enabled per clone by tools/bundle.py, which sets `core.hooksPath scripts` on its
first run (git never enables hooks from repository content by itself).
Runs as a pre-commit hook, which is the only commit hook whose `git add` is Runs as a pre-commit hook, which is the only commit hook whose `git add` is
picked up by the commit currently being created: picked up by the commit currently being created:
Executable → Regular
+46
View File
@@ -2,6 +2,9 @@
"""Bundle a multi-file exteraGram plugin (plugin.json + src/) into a single .plugin file. """Bundle a multi-file exteraGram plugin (plugin.json + src/) into a single .plugin file.
Usage: python3 tools/bundle.py [--dev|--release] [--out DIR] [--check] Usage: python3 tools/bundle.py [--dev|--release] [--out DIR] [--check]
Also enables the repo's git hooks (scripts/pre-commit, scripts/post-commit) on
first run; see ensure_git_hooks().
""" """
from __future__ import annotations from __future__ import annotations
@@ -9,13 +12,18 @@ import argparse
import ast import ast
import base64 import base64
import json import json
import os
import re import re
import stat
import subprocess
import sys import sys
import zlib import zlib
from pathlib import Path from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
SRC_DIR = REPO_ROOT / "src" SRC_DIR = REPO_ROOT / "src"
HOOKS_DIR = "scripts"
HOOK_NAMES = ("pre-commit", "post-commit")
PLUGIN_JSON = REPO_ROOT / "plugin.json" PLUGIN_JSON = REPO_ROOT / "plugin.json"
RECOGNIZED_KEYS = { RECOGNIZED_KEYS = {
@@ -300,6 +308,43 @@ def assemble_bundle(data: dict, modules: dict, release: bool) -> str:
return text return text
def ensure_git_hooks() -> None:
"""Point git at scripts/ for hooks, once per clone.
Git never enables hooks from repository content, so a fresh clone (or a
repo created from the template) starts without them. Every workflow runs
this bundler first, directly or via scripts/deploy.sh and scripts/watch.sh,
which makes it the one place that can switch them on without an extra
setup step. Skipped in CI, outside a git work tree, and when
core.hooksPath is already set (including to something else on purpose).
Never fails the build.
"""
if os.environ.get("CI"):
return
hooks = [REPO_ROOT / HOOKS_DIR / name for name in HOOK_NAMES]
if not any(h.is_file() for h in hooks):
return
def git(*args):
return subprocess.run(["git", *args], cwd=REPO_ROOT, capture_output=True, text=True)
try:
top = git("rev-parse", "--show-toplevel")
if top.returncode != 0 or Path(top.stdout.strip()).resolve() != REPO_ROOT:
return
if git("config", "--get", "core.hooksPath").stdout.strip():
return
# Git silently skips hooks that are not executable.
for hook in hooks:
if hook.is_file():
mode = hook.stat().st_mode
hook.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
if git("config", "core.hooksPath", HOOKS_DIR).returncode == 0:
print(f"enabled git hooks from {HOOKS_DIR}/ (git config core.hooksPath {HOOKS_DIR})", file=sys.stderr)
except (OSError, subprocess.SubprocessError):
pass
def parse_args(argv): def parse_args(argv):
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Bundle a multi-file exteraGram plugin into a single .plugin file." description="Bundle a multi-file exteraGram plugin into a single .plugin file."
@@ -318,6 +363,7 @@ def parse_args(argv):
def main(argv=None) -> int: def main(argv=None) -> int:
args = parse_args(sys.argv[1:] if argv is None else argv) args = parse_args(sys.argv[1:] if argv is None else argv)
ensure_git_hooks()
release = bool(args.release) release = bool(args.release)
try: try:
data = load_metadata() data = load_metadata()