diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..75d13518 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,112 @@ +name: release + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + id-token: write # required for AWS OIDC + contents: write # required to create the tag + GitHub release + +# Serialize releases so concurrent runs can't compute the same micro version. +concurrency: + group: release + cancel-in-progress: false + +jobs: + plan: + name: Decide release + runs-on: ubuntu-latest + outputs: + should_release: ${{ steps.plan.outputs.should_release }} + version: ${{ steps.plan.outputs.version }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 # need full tag + commit history + + - name: Decide whether to release and compute CalVer + id: plan + run: | + set -euo pipefail + + last_tag=$(git tag --sort=-creatordate | head -n1) + if [ -n "${last_tag}" ]; then + range="${last_tag}..HEAD" + else + range="HEAD" + fi + + # Release when there is a release-worthy Conventional Commit since the + # last release. + subjects=$(git log --no-merges --format='%s' ${range}) + bodies=$(git log --no-merges --format='%b' ${range}) + if printf '%s\n' "${subjects}" | grep -qiE '^(feat|fix)(\([^)]+\))?!?:' \ + || printf '%s\n' "${subjects}" | grep -qE '^[a-zA-Z]+(\([^)]+\))?!:' \ + || printf '%s\n' "${bodies}" | grep -qE '^BREAKING[ -]CHANGE:'; then + should_release=true + else + should_release=false + fi + + # CalVer YYYY.MM.Micro. + ym=$(date -u +%Y.%m) + last=$(git tag --list "${ym}.*" | sed -E "s/^${ym}\.//" | sort -n | tail -n1) + micro=$(( ${last:--1} + 1 )) + version="${ym}.${micro}" + + echo "should_release=${should_release}" >> "$GITHUB_OUTPUT" + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "should_release=${should_release} version=${version} (range ${range})" + + release: + name: Publish splinter.json + needs: plan + if: needs.plan.outputs.should_release == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: set up python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: verify splinter.sql is up to date + run: | + python bin/compile.py + git diff --exit-code splinter.sql + + - name: generate splinter.json + run: python bin/compile_json.py --version "${{ needs.plan.outputs.version }}" --output splinter.json + + - name: publish github release (creates the tag) + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + with: + tag_name: ${{ needs.plan.outputs.version }} + files: splinter.json + generate_release_notes: true + + # - name: GitHub OIDC auth + # uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1 + # with: + # aws-region: ap-southeast-1 + # role-to-assume: ${{ secrets.SHARED_SERVICES_OIDC }} + # role-session-name: shared-services-jump + + # - name: Assume destination role + # uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1 + # with: + # aws-region: ap-southeast-1 + # role-to-assume: ${{ secrets.SHARED_SERVICES_ROLE }} + # role-skip-session-tagging: true + # role-session-name: upload-assets + # role-chaining: true + + # - name: Upload splinter.json to shared services bucket + # run: | + # aws s3 cp --no-progress splinter.json \ + # "s3://${{ secrets.SHARED_SERVICES_BUCKET }}/splinter/${{ needs.plan.outputs.version }}/splinter.json" diff --git a/.gitignore b/.gitignore index 3224229b..29607b7e 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,6 @@ dmypy.json # mac .DS_Store + +# generated in CI + published as a release artifact, never committed +/splinter.json diff --git a/bin/compile.py b/bin/compile.py index 485c7a43..e7beba90 100755 --- a/bin/compile.py +++ b/bin/compile.py @@ -59,7 +59,15 @@ def load_sql_files(directory_path) -> Dict[str, str]: return queries -sql_map = load_sql_files(LINTS_DIR) +def compile_sql(sql_map: Dict[str, str]) -> str: + return HEADER + "\n" + "\nunion all\n".join(sql_map.values()) -with open("splinter.sql", "w") as f: - f.write(HEADER + "\n" + "\nunion all\n".join(sql_map.values())) + +def main() -> None: + sql_map = load_sql_files(LINTS_DIR) + with open("splinter.sql", "w") as f: + f.write(compile_sql(sql_map)) + + +if __name__ == "__main__": + main() diff --git a/bin/compile_json.py b/bin/compile_json.py new file mode 100644 index 00000000..2ad885cd --- /dev/null +++ b/bin/compile_json.py @@ -0,0 +1,100 @@ +"""Generate splinter.json, a structured manifest of the splinter lints. + +Unlike splinter.sql (a single monolithic `union all` query), this emits each +lint as a discrete, self-describing entry so downstream consumers can select, +filter, and combine lints by reading fields instead of parsing SQL text. + +Shape: + { + "version": "", + "setup": "", + "lints": [ { "name", "categories", "query" }, ... ] + } + +`query` is the same union-ready, parenthesized SQL that compile.py emits for +splinter.sql; consumers treat it as opaque. `name` and `categories` are lifted +out of each lint's SQL here, at build time, so a malformed lint fails CI loudly +rather than a consumer's regex failing silently against a live database. + +This file is NOT committed; it is generated in CI and published as a release +artifact (see .github/workflows/release.yml). +""" + +import argparse +import json +import os +import re +import sys +from typing import Dict, List + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import compile # noqa: E402 (bin/compile.py; import after sys.path setup) + +NAME_RE = re.compile(r"'([a-z0-9_]+)'\s+as\s+name", re.IGNORECASE) +CATEGORIES_RE = re.compile( + r"array\[(.*?)\]\s+as\s+categories", re.IGNORECASE | re.DOTALL +) + + +def extract_name(stem: str, query: str) -> str: + matches = NAME_RE.findall(query) + if len(matches) != 1: + raise SystemExit( + f"lint {stem!r}: expected exactly one \"'' as name\", " + f"found {len(matches)}" + ) + return matches[0] + + +def extract_categories(stem: str, query: str) -> List[str]: + match = CATEGORIES_RE.search(query) + if not match: + raise SystemExit(f'lint {stem!r}: could not find "array[...] as categories"') + categories = [ + part.strip().strip("'\"") for part in match.group(1).split(",") if part.strip() + ] + if not categories: + raise SystemExit(f"lint {stem!r}: empty categories array") + return categories + + +def build_manifest(version: str) -> Dict[str, object]: + sql_map = compile.load_sql_files(compile.LINTS_DIR) + lints = [ + { + "name": extract_name(stem, query), + "categories": extract_categories(stem, query), + "query": query, + } + for stem, query in sql_map.items() + ] + return { + "version": version, + "setup": compile.HEADER.strip(), + "lints": lints, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Generate splinter.json manifest") + parser.add_argument( + "--version", + default="0.0.0-dev", + help="Manifest version, CalVer YYYY.MM.Micro (defaults to a dev placeholder)", + ) + parser.add_argument("--output", default="splinter.json") + args = parser.parse_args() + + manifest = build_manifest(args.version) + with open(args.output, "w") as f: + json.dump(manifest, f, indent=2) + f.write("\n") + + print( + f"wrote {args.output}: {len(manifest['lints'])} lints, version {args.version}" + ) + + +if __name__ == "__main__": + main()