diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d9393ed --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.swift] +indent_size = 4 + +[*.{rs,go}] +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2e2bb58 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,46 @@ +# Normalize all text files to LF +* text=auto eol=lf + +# Scripts -- must be LF (executed in Docker/Linux) +*.sh text eol=lf +*.bash text eol=lf + +# Data/config +*.sql text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.json text eol=lf +*.toml text eol=lf +*.properties text eol=lf + +# Source code +*.scala text eol=lf +*.kt text eol=lf +*.kts text eol=lf +*.java text eol=lf +*.swift text eol=lf +*.ts text eol=lf +*.js text eol=lf +*.rs text eol=lf +*.go text eol=lf + +# Docs +*.md text eol=lf +*.txt text eol=lf + +# Docker +Dockerfile text eol=lf +docker-compose*.yml text eol=lf + +# Binary -- never touch +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.jar binary +*.zip binary +*.tar.gz binary +*.woff binary +*.woff2 binary +*.ttf binary diff --git a/.github/workflows/lint-files.yml b/.github/workflows/lint-files.yml new file mode 100644 index 0000000..995132e --- /dev/null +++ b/.github/workflows/lint-files.yml @@ -0,0 +1,41 @@ +name: Lint Files + +on: + workflow_call: + +jobs: + file-hygiene: + name: File Hygiene (editorconfig) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: EditorConfig Checker + uses: editorconfig-checker/action-editorconfig-checker@d2ed4fd072ae6f887e9407c909af0f585d2ad9f4 # v2 + + shell-lint: + name: Shell Scripts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: ShellCheck + uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0 + with: + severity: warning + + markdown-lint: + name: Markdown + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: markdownlint + uses: DavidAnson/markdownlint-cli2-action@ce4853d43830c74c1753b39f3cf40f71c2031eb9 # v23 + with: + globs: "**/*.md" + + yaml-lint: + name: YAML + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: yamllint + uses: frenck/action-yamllint@e3e8cdef144eeb914883bd3c77f0333227140ffc # v1 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..21289a1 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,10 @@ +name: Lint + +on: + pull_request: + push: + branches: [main] + +jobs: + lint: + uses: hyperledger-identus/.github/.github/workflows/lint-files.yml@main diff --git a/.markdownlint.yml b/.markdownlint.yml new file mode 100644 index 0000000..60f19d0 --- /dev/null +++ b/.markdownlint.yml @@ -0,0 +1,17 @@ +# markdownlint configuration +# https://github.com/DavidAnson/markdownlint/blob/main/doc/Rules.md + +default: true + +# Allow long lines (common in tables, URLs, and generated content) +MD013: false + +# Allow multiple top-level headings (common in multi-section docs) +MD025: false + +# Allow inline HTML (needed for badges, details/summary, etc.) +MD033: false + +# Allow duplicate headings in different sections +MD024: + siblings_only: true diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 0000000..189ed94 --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,13 @@ +--- +extends: default + +rules: + # Allow long lines (common in CI workflows and docker-compose) + line-length: disable + + # Allow truthy values like 'on' (used in GitHub Actions triggers) + truthy: + allowed-values: ["true", "false", "yes", "no", "on"] + + # Relaxed comment indentation + comments-indentation: disable diff --git a/scripts/check-file-hygiene.py b/scripts/check-file-hygiene.py new file mode 100755 index 0000000..c35afb6 --- /dev/null +++ b/scripts/check-file-hygiene.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +"""Check that all Identus repos contain the canonical hygiene config files. + +Compares .gitattributes, .editorconfig, .markdownlint.yml, and .yamllint.yml +in every repo against the templates in the .github repo. + +Uses prefix matching: a repo's file is considered OK if it starts with the +canonical template content. This allows repos to append local overrides +(e.g., ktlint rules in .editorconfig) while still enforcing the shared +baseline. + +Requires: gh CLI authenticated with access to hyperledger-identus org. + +Usage: + python3 scripts/check-file-hygiene.py +""" + +import argparse +import base64 +import json +import subprocess +import sys +from pathlib import Path + +ORG = "hyperledger-identus" +TEMPLATE_REPO = ".github" +FILES = [".gitattributes", ".editorconfig", ".markdownlint.yml", ".yamllint.yml"] + +# Repos to skip (the template repo itself) +SKIP_REPOS = {TEMPLATE_REPO} + + +def gh_api(endpoint: str) -> dict | list | None: + """Call the GitHub API via gh CLI.""" + result = subprocess.run( + ["gh", "api", endpoint, "--paginate"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + return json.loads(result.stdout) + + +def get_repo_names() -> list[str]: + """Get all repo names in the org.""" + repos = gh_api(f"orgs/{ORG}/repos?per_page=100") + if not repos: + print("Error: could not fetch repos. Is `gh` authenticated?", file=sys.stderr) + sys.exit(1) + return sorted(r["name"] for r in repos if not r["archived"]) + + +def get_file_content(repo: str, path: str, ref: str = "main") -> str | None: + """Fetch a file's content from a repo via the GitHub API.""" + data = gh_api(f"repos/{ORG}/{repo}/contents/{path}?ref={ref}") + if not data or "content" not in data: + return None + return base64.b64decode(data["content"]).decode("utf-8") + + +def load_template(path: str) -> str: + """Load a template file from the local .github repo.""" + template_path = Path(__file__).resolve().parent.parent / path + if not template_path.exists(): + print(f"Error: template {template_path} not found", file=sys.stderr) + sys.exit(1) + return template_path.read_text() + + +def main(): + parser = argparse.ArgumentParser(description="Check file hygiene across Identus repos") + parser.parse_args() + + repos = get_repo_names() + templates = {f: load_template(f) for f in FILES} + + # Status tracking + results: dict[str, dict[str, str]] = {} + + for repo in repos: + if repo in SKIP_REPOS: + continue + results[repo] = {} + for filename in FILES: + content = get_file_content(repo, filename) + if content is None: + results[repo][filename] = "MISSING" + elif content.startswith(templates[filename].rstrip()): + results[repo][filename] = "OK" + else: + results[repo][filename] = "OUTDATED" + + # Print table + col_width = max(len(r) for r in results) + 2 + file_widths = {f: max(len(f), 8) + 2 for f in FILES} + + header = "Repo".ljust(col_width) + "".join(f.ljust(file_widths[f]) for f in FILES) + print(header) + print("-" * len(header)) + + all_ok = True + for repo, statuses in results.items(): + row = repo.ljust(col_width) + for filename in FILES: + status = statuses[filename] + if status != "OK": + all_ok = False + row += status.ljust(file_widths[filename]) + print(row) + + # Summary + print() + total = len(results) * len(FILES) + ok_count = sum(1 for r in results.values() for s in r.values() if s == "OK") + missing = sum(1 for r in results.values() for s in r.values() if s == "MISSING") + outdated = sum(1 for r in results.values() for s in r.values() if s == "OUTDATED") + print(f"Total: {ok_count}/{total} OK, {missing} missing, {outdated} outdated") + + if not all_ok: + sys.exit(1) + + +if __name__ == "__main__": + main()