mirror of
https://github.com/penpot/penpot.git
synced 2026-05-13 12:04:06 +00:00
* ✨ Add additional logging and validation for image upload * 🎉 Add chunked upload support for font variants Extend the font variant upload flow across frontend, backend, and common to support the standardized chunked upload protocol. **Backend:** - Add \`:font-max-file-size\` config default (30 MiB) and schema entry - Add \`validate-font-size!\` in \`media.clj\` (mirrors \`validate-media-size!\`, raises \`:font-max-file-size-reached\`) - Extend \`schema:create-font-variant\` to accept either \`:data\` (legacy bytes or chunk-vector) or \`:uploads\` (new chunked session map), with a validator requiring exactly one - Add \`prepare-font-data-from-uploads\`: assembles each chunked session via \`cmedia/assemble-chunks\`, validates type+size - Add \`prepare-font-data-from-legacy\`: normalises legacy byte/chunk entries, writing to a tempfile (joining via SequenceInputStream), validates type+size - Add structured logging ("init"/"end") with \`:size\`, \`:mtypes\`, and \`:elapsed\` in \`create-font-variant\` **Frontend:** - \`upload-blob-chunked\` accepts a per-caller \`:chunk-size\` option - Add \`font-upload-chunk-size\` (10 MiB) and \`upload-font-variant\` fn that uploads each mtype as a separate chunked session - \`on-upload*\` in dashboard fonts now calls \`upload-font-variant\` instead of issuing \`create-font-variant\` RPC directly - \`process-upload\` stores raw ArrayBuffer instead of chunking client-side **Common:** - Replace \`"font/opentype"\` with \`"font/woff2"\` in \`font-types\` **Tests:** - 25 tests / 224 assertions covering all three upload paths (direct bytes, legacy chunk-vector, new chunked sessions), size validation, and media type validation Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 📎 Add a script for check the commit format locally --------- Signed-off-by: Andrey Antukh <niwi@niwi.nz>
209 lines
7.3 KiB
Python
Executable File
209 lines
7.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Check commit messages against Penpot's commit guidelines.
|
|
|
|
Validates commit messages using the rules defined in:
|
|
- .github/workflows/commit-checker.yml (regex pattern)
|
|
- CONTRIBUTING.md (formatting rules, subject length, DCO)
|
|
|
|
By default, checks HEAD. Use --commit to specify a different commit.
|
|
|
|
Usage:
|
|
./scripts/check-commit
|
|
./scripts/check-commit --commit HEAD~1
|
|
./scripts/check-commit -c abc1234
|
|
"""
|
|
|
|
import argparse
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
# ── Emoji list ───────────────────────────────────────────────────────────────
|
|
# Combined from commit-checker.yml AND CONTRIBUTING.md
|
|
VALID_EMOJIS = (
|
|
"lipstick|globe_with_meridians|wrench|books|"
|
|
"arrow_up|arrow_down|zap|ambulance|construction|"
|
|
"boom|fire|whale|bug|sparkles|paperclip|tada|"
|
|
"recycle|rewind|construction_worker|rocket"
|
|
)
|
|
|
|
# ── Regex from .github/workflows/commit-checker.yml ──────────────────────────
|
|
# Matches:
|
|
# 1) ":emoji: <Capitalized subject without trailing dot>"
|
|
# 2) "Merge|Revert|Reapply ... without trailing dot"
|
|
COMMIT_PATTERN = re.compile(
|
|
r"^((:(" + VALID_EMOJIS + r"):\s[A-Z].*[^.]))$"
|
|
)
|
|
|
|
MERGE_PATTERN = re.compile(r"^(Merge|Revert|Reapply).+[^.]$")
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Helpers
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
def run_git(args):
|
|
"""Run a git command and return (returncode, stdout, stderr)."""
|
|
try:
|
|
result = subprocess.run(
|
|
["git"] + args,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
return result.returncode, result.stdout, result.stderr
|
|
except FileNotFoundError:
|
|
print("ERROR: git not found. Is it installed?", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def get_commit_message(commit_ref):
|
|
"""Return the full commit message for *commit_ref*."""
|
|
rc, out, err = run_git(["log", "--format=%B", "-n", "1", commit_ref])
|
|
if rc != 0:
|
|
print(f"ERROR: could not read commit {commit_ref}: {err.strip()}", file=sys.stderr)
|
|
sys.exit(1)
|
|
if not out.strip():
|
|
print(f"ERROR: commit {commit_ref} has no message", file=sys.stderr)
|
|
sys.exit(1)
|
|
return out.rstrip("\n")
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Validators
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
def check_regex(message):
|
|
"""Check the commit message against the CI regex pattern."""
|
|
# Normalise: strip trailing newlines for single-line matching
|
|
first_line = message.split("\n")[0]
|
|
|
|
if MERGE_PATTERN.match(first_line):
|
|
return True, None
|
|
|
|
if COMMIT_PATTERN.match(first_line):
|
|
return True, None
|
|
|
|
return False, (
|
|
"Commit subject must match one of:\n"
|
|
" :emoji: <Capitalized subject without trailing dot>\n"
|
|
" Merge|Revert|Reapply <rest without trailing dot>\n"
|
|
f"Got: {first_line!r}"
|
|
)
|
|
|
|
|
|
def check_subject_length(message):
|
|
"""Subject line must be ≤ 90 characters."""
|
|
first_line = message.split("\n")[0]
|
|
if len(first_line) > 90:
|
|
return False, (
|
|
f"Subject line exceeds 90 characters ({len(first_line)} chars):\n"
|
|
f" {first_line}"
|
|
)
|
|
return True, None
|
|
|
|
|
|
def check_subject_no_trailing_dot(message):
|
|
"""Subject line must not end with a period ('.')."""
|
|
first_line = message.split("\n")[0]
|
|
if first_line.endswith("."):
|
|
return False, (
|
|
"Subject line must not end with a period:\n"
|
|
f" {first_line}"
|
|
)
|
|
return True, None
|
|
|
|
|
|
def check_subject_capitalized(message):
|
|
"""Subject must be capitalized, but only if it's a regular commit (not Merge/Revert/Reapply)."""
|
|
first_line = message.split("\n")[0]
|
|
|
|
# Skip check for Merge/Revert/Reapply commits
|
|
if MERGE_PATTERN.match(first_line):
|
|
return True, None
|
|
|
|
# Strip emoji prefix before checking capitalization
|
|
emoji_match = re.match(r"^:([a-z_]+):\s+(.*)", first_line)
|
|
if emoji_match:
|
|
rest = emoji_match.group(2)
|
|
else:
|
|
rest = first_line
|
|
|
|
if rest and not rest[0].isupper():
|
|
return False, (
|
|
"Subject line must start with a capital letter "
|
|
"(after the emoji prefix):\n"
|
|
f" {first_line}"
|
|
)
|
|
return True, None
|
|
|
|
|
|
def check_body_blank_line(message):
|
|
"""If a body exists, there must be a blank line between subject and body."""
|
|
lines = message.split("\n")
|
|
if len(lines) >= 3 and lines[1] != "":
|
|
return False, (
|
|
"A blank line must separate the subject from the body."
|
|
)
|
|
return True, None
|
|
|
|
|
|
def check_signed_off_by(message):
|
|
"""Check for the DCO Signed-off-by line (required for code changes)."""
|
|
if "Signed-off-by:" not in message:
|
|
return False, (
|
|
"Missing 'Signed-off-by:' line in the commit footer.\n"
|
|
" Add it with 'git commit -s' or append it manually:\n"
|
|
" Signed-off-by: Your Real Name <your.email@example.com>"
|
|
)
|
|
return True, None
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Check a commit message against Penpot commit guidelines."
|
|
)
|
|
parser.add_argument(
|
|
"-c", "--commit",
|
|
default="HEAD",
|
|
help="Commit to check (default: HEAD)",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
commit_ref = args.commit
|
|
message = get_commit_message(commit_ref)
|
|
|
|
print(f"Checking commit {commit_ref} ...\n")
|
|
|
|
validators = [
|
|
("Regex pattern", check_regex),
|
|
("Subject ≤ 90 chars", check_subject_length),
|
|
("No trailing period in subject", check_subject_no_trailing_dot),
|
|
("Subject capitalized", check_subject_capitalized),
|
|
("Blank line after subject", check_body_blank_line),
|
|
]
|
|
|
|
all_ok = True
|
|
|
|
for name, validator in validators:
|
|
ok, error_msg = validator(message)
|
|
status = "✓" if ok else "✗"
|
|
print(f" [{status}] {name}")
|
|
if not ok:
|
|
all_ok = False
|
|
print(f" {error_msg}", file=sys.stderr)
|
|
|
|
print()
|
|
if all_ok:
|
|
print("All checks passed.")
|
|
sys.exit(0)
|
|
else:
|
|
print("Some checks FAILED. See messages above.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|