Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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"
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,6 @@ dmypy.json

# mac
.DS_Store

# generated in CI + published as a release artifact, never committed
/splinter.json
14 changes: 11 additions & 3 deletions bin/compile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
100 changes: 100 additions & 0 deletions bin/compile_json.py
Original file line number Diff line number Diff line change
@@ -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": "<CalVer YYYY.MM.Micro>",
"setup": "<prelude run once, verbatim, before the lints>",
"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 \"'<name>' 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()
Loading