diff --git a/.github/workflows/check-deployed-package.yml b/.github/workflows/check-deployed-package.yml index 3f40373..298fe15 100644 --- a/.github/workflows/check-deployed-package.yml +++ b/.github/workflows/check-deployed-package.yml @@ -1,28 +1,34 @@ name: Check deployed package +# CI runs it after each release, against the version just published; by hand it checks the latest version on PyPI. on: + workflow_call: + inputs: + version: + type: string + required: true + secrets: + LOGDASH_API_KEY: + required: true + LOGDASH_PERSONAL_API_KEY: + required: true workflow_dispatch: - workflow_run: - workflows: ['Publish Package to PyPI'] - types: - - completed - branches: - - main + +permissions: + contents: read jobs: run-demo: name: Run LogDash Demo runs-on: ubuntu-latest - if: ${{ github.event.workflow_run.conclusion == 'success' }} steps: - name: Checkout repository - uses: actions/checkout@v3 - - - name: Make run script executable - run: chmod +x check-deployed-package/run.sh + uses: actions/checkout@v7 - name: Run LogDash demo env: LOGDASH_API_KEY: ${{ secrets.LOGDASH_API_KEY }} + LOGDASH_PERSONAL_API_KEY: ${{ secrets.LOGDASH_PERSONAL_API_KEY }} + LOGDASH_VERSION: ${{ inputs.version }} run: ./check-deployed-package/run.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1befbda --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,89 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + # A release in progress must finish; a newer merge waits and releases after it. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +# The repository's default token can write; only the release job needs to. +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: 3.x + - uses: actions/setup-node@v7 + with: + node-version: 24 + - run: pip install build -e . + - run: python -m unittest discover -s tests + - run: python -m build + - run: node --test 'scripts/*.test.ts' + + # The version the merged commits call for; empty when none of them is a feat, fix, perf, or breaking change. + version: + if: github.event_name == 'push' + runs-on: ubuntu-latest + outputs: + next: ${{ steps.next.outputs.version }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # the tags and commits since the last release + - uses: actions/setup-node@v7 + with: + node-version: 24 + # Two lines, so a failing script fails the job instead of releasing nothing. + - id: next + run: | + version=$(node scripts/version.ts) + echo "version=$version" >> "$GITHUB_OUTPUT" + + # Publishes the merge to PyPI as that version. + release: + needs: [test, version] + if: needs.version.outputs.next != '' + runs-on: ubuntu-latest + permissions: + contents: write + env: + V: ${{ needs.version.outputs.next }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: 3.x + # setup.py reads the version from here. + - run: sed -i "s/^__version__ = .*/__version__ = \"$V\"/" logdash/__init__.py + - run: pip install build twine && python -m build + # Fails before the upload when the version did not reach the packages. + - run: ls "dist/logdash-$V.tar.gz" "dist/logdash-$V-py3-none-any.whl" + # --skip-existing, so a rerun after a failed release step just tags. + - run: twine upload --skip-existing dist/* + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + # Last, since it creates the v$V tag the next release counts from. + - run: gh release create "v$V" --generate-notes --target "$GITHUB_SHA" + env: + GH_TOKEN: ${{ github.token }} + + # Installs the version just published and reads back what it sends. + smoke: + needs: [version, release] + uses: ./.github/workflows/check-deployed-package.yml + with: + version: ${{ needs.version.outputs.next }} + secrets: + LOGDASH_API_KEY: ${{ secrets.LOGDASH_API_KEY }} + LOGDASH_PERSONAL_API_KEY: ${{ secrets.LOGDASH_PERSONAL_API_KEY }} diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..4776c98 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,20 @@ +name: PR title + +# Squash merges use the PR title as the commit subject, and scripts/version.ts reads it to pick the next version. +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +permissions: {} + +jobs: + conventional: + runs-on: ubuntu-latest + steps: + - env: + TITLE: ${{ github.event.pull_request.title }} + run: | + printf '%s' "$TITLE" | grep -qE '^(feat|fix|perf|refactor|docs|test|build|ci|chore|style|revert)(\(.+\))?!?: .+' || { + echo "::error::PR title \"$TITLE\" is not a Conventional Commit, e.g. \"feat(metrics): histograms\" or \"fix(logger): colors on Windows\". feat releases a minor, fix/perf a patch, ! a major; other types do not release." + exit 1 + } diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml deleted file mode 100644 index cf8bca3..0000000 --- a/.github/workflows/publish-package.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Publish Package to PyPI - -on: - push: - branches: - - main - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.9' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine build - - - name: Build package - run: python -m build - - - name: Publish package - id: publish - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/check-deployed-package/Dockerfile b/check-deployed-package/Dockerfile index 352af25..55d42e4 100644 --- a/check-deployed-package/Dockerfile +++ b/check-deployed-package/Dockerfile @@ -2,7 +2,9 @@ FROM python:3.9-alpine WORKDIR /app -RUN pip install logdash +# The version to check, the latest when empty; PyPI can take a few minutes to serve one just published +ARG LOGDASH_VERSION +RUN for _ in $(seq 30); do pip install --no-cache-dir "logdash${LOGDASH_VERSION:+==$LOGDASH_VERSION}" && exit 0; sleep 10; done; exit 1 COPY check-deployed-package/check.py /app/ diff --git a/check-deployed-package/README.md b/check-deployed-package/README.md index aab9087..63306f6 100644 --- a/check-deployed-package/README.md +++ b/check-deployed-package/README.md @@ -3,5 +3,10 @@ Use command below to test if the published SDK works: ``` -LOGDASH_API_KEY= ./check-deployed-package/run.sh +LOGDASH_API_KEY= LOGDASH_PERSONAL_API_KEY= ./check-deployed-package/run.sh ``` + +`LOGDASH_API_KEY` sends the logs and metrics: the ingest key of the shared "SDK smoke tests" domain, from its Settings page. +They arrive as the `python-sdk` app on that domain. +`LOGDASH_PERSONAL_API_KEY` reads them back: a personal API key (`ldp_`) with logs:read and metrics:read, restricted to that domain. +It checks the latest version on PyPI; set `LOGDASH_VERSION` to check another one. diff --git a/check-deployed-package/check.py b/check-deployed-package/check.py index c6f3d86..986aefd 100644 --- a/check-deployed-package/check.py +++ b/check-deployed-package/check.py @@ -3,28 +3,26 @@ import os import time -import pkg_resources +from importlib.metadata import version from logdash import create_logdash print('=== LogDash SDK Demo ===') -try: - logdash_version = pkg_resources.get_distribution("logdash").version -except Exception: - logdash_version = "unknown" -print(f"Using logdash package version: {logdash_version}") +print(f"Using logdash package version: {version('logdash')}") print() api_key = os.environ.get('LOGDASH_API_KEY') logs_seed = os.environ.get('LOGS_SEED', 'default') metrics_seed = os.environ.get('METRICS_SEED', '1') -print(f"Using API Key: {api_key}") +app = os.environ.get('LOGDASH_APP', 'python-sdk') +print(f"Using App: {app}") print(f"Using Logs Seed: {logs_seed}") print(f"Using Metrics Seed: {metrics_seed}") logdash = create_logdash({ - "api_key": api_key, + "api_key": api_key, + "app": app, }) # Get the logger instance diff --git a/check-deployed-package/run.sh b/check-deployed-package/run.sh index bece365..c0aab8e 100755 --- a/check-deployed-package/run.sh +++ b/check-deployed-package/run.sh @@ -9,8 +9,11 @@ echo "Generated seed: $LOGS_SEED" METRICS_SEED=$(awk 'BEGIN{srand(); print int(rand()*1000000)+1}') echo "Generated metrics seed: $METRICS_SEED" -echo "Building LogDash demo Docker image (using published package)..." -docker build --no-cache -t logdash-python-demo -f check-deployed-package/Dockerfile . +# Every SDK sends to the shared "SDK smoke tests" domain as its own app +APP=python-sdk + +echo "Building LogDash demo Docker image (using published package ${LOGDASH_VERSION:-latest})..." +docker build --no-cache --build-arg LOGDASH_VERSION="${LOGDASH_VERSION}" -t logdash-python-demo -f check-deployed-package/Dockerfile . echo echo "Running LogDash demo..." @@ -21,48 +24,34 @@ docker run --rm \ -e LOGDASH_API_KEY="${LOGDASH_API_KEY}" \ -e LOGS_SEED="${LOGS_SEED}" \ -e METRICS_SEED="${METRICS_SEED}" \ + -e LOGDASH_APP="${APP}" \ logdash-python-demo echo -echo "Demo completed!" +echo "Demo completed!" echo echo "Authenticating with LogDash API..." -# Authenticate with API using the API key -AUTH_RESPONSE=$(curl -s -X 'POST' \ - 'https://api.logdash.io/auth/api-key' \ +# Identify the domain via the read-only personal API key (whoami). +# The ingest key (LOGDASH_API_KEY) is write-only and is no longer exchangeable for a token. +TOKEN="${LOGDASH_PERSONAL_API_KEY}" +AUTH_RESPONSE=$(curl -s -X 'GET' \ + 'https://api.logdash.io/personal-api-keys/whoami' \ -H 'accept: application/json' \ - -H 'Content-Type: application/json' \ - -d "{ - \"apiKey\": \"${LOGDASH_API_KEY}\" -}") + -H "Authorization: Bearer ${TOKEN}") -# Extract token and projectId from response -TOKEN=$(echo "$AUTH_RESPONSE" | grep -o '"token":"[^"]*"' | sed 's/"token":"\(.*\)"/\1/') -PROJECT_ID=$(echo "$AUTH_RESPONSE" | grep -o '"projectId":"[^"]*"' | sed 's/"projectId":"\(.*\)"/\1/') +# The key is restricted to the one smoke-test domain; whoami returns it as access { kind: "clusters", ids: [domainId] }. +DOMAIN_ID=$(echo "$AUTH_RESPONSE" | jq -r 'if .access.kind == "clusters" then .access.ids[0] // empty else empty end' 2>/dev/null || true) -if [ -z "$TOKEN" ] || [ -z "$PROJECT_ID" ]; then +if [ -z "$TOKEN" ] || [ -z "$DOMAIN_ID" ]; then echo "Error: Failed to authenticate with LogDash API" + echo "LOGDASH_PERSONAL_API_KEY must be a personal API key (ldp_) with logs:read and metrics:read, restricted to the \"SDK smoke tests\" domain" echo "Response: $AUTH_RESPONSE" exit 1 fi -echo "Authentication successful. Project ID: $PROJECT_ID" - -echo -echo "Fetching logs from LogDash API..." - -# Fetch logs from the API -LOGS_RESPONSE=$(curl -s -X 'GET' \ - "https://api.logdash.io/projects/${PROJECT_ID}/logs?limit=10" \ - -H 'accept: application/json' \ - -H "Authorization: Bearer ${TOKEN}") - -echo "Logs fetched successfully" - -echo -echo "Validating log messages..." +echo "Authentication successful. Domain ID: $DOMAIN_ID" # Expected log messages with seed EXPECTED_MESSAGES="This is an info log ${LOGS_SEED} @@ -74,43 +63,55 @@ This is a silly log ${LOGS_SEED} This is an info log ${LOGS_SEED} This is a verbose log ${LOGS_SEED}" -# Check if all expected messages are present in the logs -echo "$EXPECTED_MESSAGES" | while IFS= read -r expected_msg; do - if ! echo "$LOGS_RESPONSE" | grep -q "$expected_msg"; then - echo "Error: Expected log message not found: '$expected_msg'" - echo "Logs response: $LOGS_RESPONSE" - exit 1 - fi - echo "✓ Found: '$expected_msg'" +# Expected users metric value (metrics_seed + 1) +EXPECTED_USERS_VALUE=$((METRICS_SEED + 1)) + +echo +echo "Fetching logs and metrics from LogDash API..." + +# The backend stores what it receives about once a second, so read until all of it is there, for up to 30 seconds +for _ in 1 2 3 4 5 6 7 8 9 10; do + LOGS_RESPONSE=$(curl -s -X 'GET' \ + "https://api.logdash.io/clusters/${DOMAIN_ID}/logs?apps=${APP}&limit=100&searchString=${LOGS_SEED}" \ + -H 'accept: application/json' \ + -H "Authorization: Bearer ${TOKEN}") + METRICS_RESPONSE=$(curl -s -X 'GET' \ + "https://api.logdash.io/clusters/${DOMAIN_ID}/metrics" \ + -H 'accept: application/json' \ + -H "Authorization: Bearer ${TOKEN}") + + MISSING_MESSAGES=$(echo "$EXPECTED_MESSAGES" | while IFS= read -r expected_msg; do + echo "$LOGS_RESPONSE" | grep -q "$expected_msg" || echo "$expected_msg" + done) + USERS_VALUE=$(echo "$METRICS_RESPONSE" | jq -r --arg app "$APP" '.[] | select(.app == $app and .name == "users") | .value' 2>/dev/null || true) + + [ -z "$MISSING_MESSAGES" ] && [ "$USERS_VALUE" = "$EXPECTED_USERS_VALUE" ] && break + sleep 3 done echo -echo "Fetching metrics from LogDash API..." +echo "Validating log messages..." -# Fetch metrics from the API -METRICS_RESPONSE=$(curl -s -X 'GET' \ - "https://api.logdash.io/projects/${PROJECT_ID}/metrics" \ - -H 'accept: application/json' \ - -H "Authorization: Bearer ${TOKEN}") +if [ -n "$MISSING_MESSAGES" ]; then + echo "Error: Expected log messages not found:" + echo "$MISSING_MESSAGES" + echo "Logs response: $LOGS_RESPONSE" + exit 1 +fi -echo "Metrics fetched successfully" +echo "$EXPECTED_MESSAGES" | while IFS= read -r expected_msg; do + echo "✓ Found: '$expected_msg'" +done echo echo "Validating metrics..." -# Expected users metric value (metrics_seed + 1) -EXPECTED_USERS_VALUE=$((METRICS_SEED + 1)) - -# Check if users metric exists with correct value -if ! echo "$METRICS_RESPONSE" | grep -q '"name":"users"'; then +if [ -z "$USERS_VALUE" ]; then echo "Error: Users metric not found" echo "Metrics response: $METRICS_RESPONSE" exit 1 fi -# Extract the value of the users metric and check if it matches expected value -USERS_VALUE=$(echo "$METRICS_RESPONSE" | sed 's/},{/}\n{/g' | grep '"name":"users"' | grep -o '"value":[0-9]*' | sed 's/"value"://') - if [ "$USERS_VALUE" != "$EXPECTED_USERS_VALUE" ]; then echo "Error: Users metric value mismatch. Expected: $EXPECTED_USERS_VALUE, Found: $USERS_VALUE" echo "Metrics response: $METRICS_RESPONSE" @@ -121,4 +122,4 @@ echo "✓ Found users metric with correct value: $USERS_VALUE" echo echo "All expected log messages and metrics found successfully!" -echo "Validation completed!" \ No newline at end of file +echo "Validation completed!" diff --git a/logdash/__init__.py b/logdash/__init__.py index 30f47b3..277e8f4 100644 --- a/logdash/__init__.py +++ b/logdash/__init__.py @@ -3,4 +3,5 @@ from logdash.metrics import MetricOperation __all__ = ["create_logdash", "Logger", "MetricOperation"] -__version__ = "1.1.0" \ No newline at end of file +# A placeholder: CI writes the version it releases here before it builds the package +__version__ = "0.0.0" diff --git a/scripts/version.test.ts b/scripts/version.test.ts new file mode 100644 index 0000000..316cade --- /dev/null +++ b/scripts/version.test.ts @@ -0,0 +1,16 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { nextVersion } from './version.ts' + +test('the next version follows the Conventional Commits since the last release', () => { + assert.equal(nextVersion(null, []), '0.1.0', 'first release') + assert.equal(nextVersion('0.1.0', []), null, 'nothing merged') + assert.equal(nextVersion('0.1.0', ['chore: deps', 'ci(release): cache', 'docs: readme']), null, 'nothing users get') + assert.equal(nextVersion('0.1.3', ['fix: crash on #', 'chore: x']), '0.1.4') + assert.equal(nextVersion('0.1.3', ['perf(export): faster']), '0.1.4') + assert.equal(nextVersion('0.1.3', ['fix: a', 'feat(timeline): b']), '0.2.0') + assert.equal(nextVersion('0.9.3', ['feat!: new format']), '1.0.0') + assert.equal(nextVersion('0.9.3', ['refactor(projects): x\n\nBREAKING CHANGE: old bundles no longer open']), '1.0.0') + assert.equal(nextVersion('1.2.3', ['Feature: no prefix', 'fixed: typo', 'feat : spaced', 'featx: y']), null, 'only exact types count') + assert.equal(nextVersion('1.2.3', ['chore: x\n\n* feat: from a squashed commit list']), null, 'only subjects count') +}) diff --git a/scripts/version.ts b/scripts/version.ts new file mode 100644 index 0000000..47a0d88 --- /dev/null +++ b/scripts/version.ts @@ -0,0 +1,26 @@ +// `node scripts/version.ts`: the version the commits since the last release call for, from their +// Conventional Commit subjects, or nothing when none of them changes the app. CI runs it after each +// merge to main and releases what it prints. Releases are tagged v; the tags are the +// source of truth; `__version__` in logdash/__init__.py is the placeholder CI overwrites before packaging. +import { execFileSync } from 'node:child_process' + +/** The next version after `last` (null before the first release), given the commit messages since it. */ +export function nextVersion(last: string | null, messages: string[]): string | null { + if (!last) return '0.1.0' + const subjects = messages.map((m) => m.split('\n')[0]) + const [major, minor, patch] = last.split('.').map(Number) + if (subjects.some((s) => /^\w+(\(.+\))?!:/.test(s)) || messages.some((m) => /^BREAKING[ -]CHANGE:/m.test(m))) return `${major + 1}.0.0` + if (subjects.some((s) => /^feat(\(.+\))?:/.test(s))) return `${major}.${minor + 1}.0` + if (subjects.some((s) => /^(fix|perf)(\(.+\))?:/.test(s))) return `${major}.${minor}.${patch + 1}` + return null +} + +if (import.meta.main) { + const git = (...args: string[]) => execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) + let tag: string | null = null + try { + tag = git('describe', '--tags', '--abbrev=0', '--match', 'v[0-9]*').trim() + } catch {} // no release yet + const messages = tag ? git('log', '--format=%B%x00', `${tag}..HEAD`).split('\0').map((m) => m.trim()).filter(Boolean) : [] + console.log(nextVersion(tag?.slice(1) ?? null, messages) ?? '') +}