diff --git a/.github/workflows/approval.yml b/.github/workflows/approval.yml new file mode 100644 index 0000000..47411b6 --- /dev/null +++ b/.github/workflows/approval.yml @@ -0,0 +1,45 @@ +name: Approval + +# Logdash team members merge their own pull requests on green checks. +# Pull requests from anyone else need a team member to approve their latest commit; outside collaborators cannot approve each other's. +# GitHub judges a required check by its newest run, so every run reads the reviews as they stand and never skips. +# ponytail: anyone with write access can edit this file on their branch, so it stops accidental merges, not deliberate ones. +on: + pull_request: + pull_request_review: + types: [submitted, dismissed] + +permissions: + pull-requests: read + +jobs: + approval: + name: team approval + runs-on: ubuntu-latest + steps: + - env: + GH_TOKEN: ${{ github.token }} + AUTHOR: ${{ github.event.pull_request.author_association }} + PR: ${{ github.event.pull_request.number }} + HEAD: ${{ github.event.pull_request.head.sha }} + run: | + case "$AUTHOR" in OWNER|MEMBER) echo "Opened by a Logdash team member."; exit 0 ;; esac + # Private organization members can show up as CONTRIBUTOR or NONE here, so a team review is a user review with write access from anyone but an outside collaborator. + # shellcheck disable=SC2016 # $owner, $name and $pr are GraphQL variables, not shell ones. + verdict=$(gh api graphql -F pr="$PR" -f owner="${GITHUB_REPOSITORY%/*}" -f name="${GITHUB_REPOSITORY#*/}" -f query=' + query($owner: String!, $name: String!, $pr: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $pr) { + latestOpinionatedReviews(first: 100, writersOnly: true) { + nodes { state authorAssociation author { __typename } commit { oid } } + } + } + } + }' | jq -r --arg head "$HEAD" ' + [.data.repository.pullRequest.latestOpinionatedReviews.nodes[] | select(.author.__typename == "User" and .authorAssociation != "COLLABORATOR")] + | if any(.state == "CHANGES_REQUESTED") then "A Logdash team member requested changes" + elif any(.state == "APPROVED" and .commit.oid == $head) then "approved" + else "No Logdash team member has approved the latest commit yet" end') + [ "$verdict" = approved ] && { echo "Approved by a Logdash team member."; exit 0; } + echo "::error::$verdict. Outside contributions merge once a Logdash team member approves their latest commit." + exit 1 diff --git a/.github/workflows/check-deployed-package.yml b/.github/workflows/check-deployed-package.yml index 2a19f62..b7a00e7 100644 --- a/.github/workflows/check-deployed-package.yml +++ b/.github/workflows/check-deployed-package.yml @@ -1,24 +1,35 @@ name: Check deployed package +# The gem installs from RubyGems and what it sends arrives. CI runs it after each release against the version it +# published; run it by hand on any branch for the latest or any other version. on: - push: - branches: - - main - - add-demo-section + workflow_call: + inputs: + version: + type: string + required: true + secrets: + LOGDASH_API_KEY: + required: true + LOGDASH_PERSONAL_API_KEY: + required: true + workflow_dispatch: + inputs: + version: + description: Version to check, the latest when empty + type: string + +permissions: + contents: read jobs: run-demo: name: Run LogDash Demo runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Make run script executable - run: chmod +x demo/run.sh - - - name: Run LogDash demo + - uses: actions/checkout@v7 + - run: ./demo/run.sh env: + LOGDASH_VERSION: ${{ inputs.version }} LOGDASH_API_KEY: ${{ secrets.LOGDASH_API_KEY }} - run: ./demo/run.sh + LOGDASH_PERSONAL_API_KEY: ${{ secrets.LOGDASH_PERSONAL_API_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..082c6e1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,86 @@ +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' }} + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + - run: bundle exec rspec + - run: bundle exec rubocop + - run: gem build logdash.gemspec + - uses: actions/setup-node@v7 + with: + node-version: 24 + - 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 + # The assignment fails the step when the script fails; inside echo the failure would vanish into an empty version. + - id: next + run: | + version=$(node scripts/version.ts) + echo "version=$version" >> "$GITHUB_OUTPUT" + + # Publishes the merge to RubyGems as its trusted publisher, with a short-lived OIDC token instead of an API key. + release: + needs: [test, version] + if: needs.version.outputs.next != '' + runs-on: ubuntu-latest + permissions: + contents: write # the GitHub release and its tag + id-token: write # the OIDC token RubyGems exchanges for a push key + env: + V: ${{ needs.version.outputs.next }} + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + # gem push fails if this wrote nothing: the gem then builds as another version than the file it pushes. + - run: sed -i "s/VERSION = '.*'/VERSION = '$V'/" lib/logdash/version.rb + - run: gem build logdash.gemspec + - uses: rubygems/configure-rubygems-credentials@v2.1.0 + # Skipped when the version is already live, so a rerun after a failed release step still tags it. + - run: curl -fs "https://rubygems.org/api/v2/rubygems/logdash/versions/$V.json" >/dev/null || gem push "logdash-$V.gem" + # Last, since it creates the v tag version.ts counts from. + - run: gh release create "v$V" --generate-notes --target "$GITHUB_SHA" + env: + GH_TOKEN: ${{ github.token }} + + # The gem just published installs from RubyGems, and what it sends arrives. + 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..06689c4 --- /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): add histograms\" or \"fix(logger): send logs in order\". feat releases a minor, fix/perf a patch, ! a major; other types do not release." + exit 1 + } diff --git a/.gitignore b/.gitignore index b04a8c8..a127546 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ # rspec failure tracking .rspec_status + +# gem build output +/*.gem diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 5b810ba..0000000 --- a/.travis.yml +++ /dev/null @@ -1,7 +0,0 @@ ---- -sudo: false -language: ruby -cache: bundler -rvm: - - 2.6.10 -before_install: gem install bundler -v 1.17.2 diff --git a/Gemfile.lock b/Gemfile.lock index 64c9c69..6e01c8b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - logdash (1.0.2) + logdash (0.0.0) json (~> 2.0) GEM diff --git a/bin/console b/bin/console index 346210d..5b1eb1b 100755 --- a/bin/console +++ b/bin/console @@ -1,7 +1,7 @@ #!/usr/bin/env ruby require "bundler/setup" -require "ruby/sdk" +require "logdash" # You can add fixtures and/or initialization code here to make experimenting # with your gem easier. You can also use a different console, if you like. diff --git a/demo/Dockerfile b/demo/Dockerfile index 5d6a554..45480d0 100644 --- a/demo/Dockerfile +++ b/demo/Dockerfile @@ -1,11 +1,13 @@ -FROM ruby:3.2-alpine +FROM ruby:3.4-alpine WORKDIR /app -RUN gem install logdash +# The version run.sh checks; ">= 0", the latest, when it is empty +ARG LOGDASH_VERSION +RUN gem install logdash --version "${LOGDASH_VERSION:->= 0}" COPY demo/demo_script.rb /app/ RUN chmod +x /app/demo_script.rb -CMD ["./demo_script.rb"] \ No newline at end of file +CMD ["./demo_script.rb"] diff --git a/demo/README.md b/demo/README.md index 07a76f9..effa3f4 100644 --- a/demo/README.md +++ b/demo/README.md @@ -1,7 +1,13 @@ # LogDash Ruby SDK Demo -Use command below to test if the published SDK works +Checks that the published gem installs from RubyGems and that what it sends arrives. +CI runs it after each release, and the `Check deployed package` workflow runs it by hand on any branch. ``` -LOGDASH_API_KEY= ./demo/run.sh +LOGDASH_API_KEY= LOGDASH_PERSONAL_API_KEY= ./demo/run.sh ``` + +It sends with the ingest key and reads the logs and metric back with the personal key, an `ldp_` key with logs:read and metrics:read restricted to the same domain. +The ingest key is the one shared by the "SDK smoke tests" domain, and the gem sends as the `ruby-sdk` app. +`LOGDASH_VERSION=` checks that version instead of the latest, and waits until RubyGems serves it. +It needs Docker, curl and jq. diff --git a/demo/demo_script.rb b/demo/demo_script.rb index 26d6c16..a8b0115 100755 --- a/demo/demo_script.rb +++ b/demo/demo_script.rb @@ -2,24 +2,17 @@ # frozen_string_literal: true require 'logdash' -require 'rubygems' -puts '=== LogDash SDK Demo ===' +seed = ENV.fetch('LOGDASH_SEED') +puts "=== LogDash SDK Demo: logdash #{Logdash::VERSION}, seed #{seed} ===" -# Get and display the logdash gem version -logdash_version = Gem.loaded_specs['logdash']&.version || 'unknown' -puts "Using logdash gem version: #{logdash_version}" -puts - -api_key = ENV.fetch('LOGDASH_API_KEY', nil) -puts "Using API Key: #{api_key}" -logdash = Logdash.create(api_key: api_key, verbose: true) +logdash = Logdash.create(api_key: ENV.fetch('LOGDASH_API_KEY'), app: 'ruby-sdk', verbose: true) logger = logdash[:logger] metrics = logdash[:metrics] -logger.info('This is an info log') -logger.error('This is an error log') -metrics.set('demo_users', 42) -metrics.mutate('demo_counter', 1) - -sleep 1 +# Each call returns the thread that sends it, which Ruby kills when the script ends, so the script waits for them. +# run.sh reads back a log of every level and demo_users at seed + 1, so the mutate goes out after the set arrived. +levels = %w[info error warn debug verbose http silly] +levels.map { |level| logger.public_send(level, 'smoke test', seed) }.each(&:join) +metrics.set('demo_users', seed.to_i).join +metrics.mutate('demo_users', 1).join diff --git a/demo/run.sh b/demo/run.sh index bb5672e..4da6a70 100755 --- a/demo/run.sh +++ b/demo/run.sh @@ -1,17 +1,51 @@ #!/bin/sh -set -e +# Installs the logdash gem from RubyGems, LOGDASH_VERSION or the latest, sends logs and a metric with it, +# and reads them back from the API. Needs Docker, curl and jq. +set -eu -echo "Building LogDash demo Docker image (using published gem)..." -docker build --no-cache -t logdash-demo -f demo/Dockerfile . +: "${LOGDASH_API_KEY:?must be the ingest key of the SDK smoke tests domain}" +api=https://api.logdash.io +seed=$(date +%s) # new every run, so the read-back finds what this run sent and nothing older -echo -echo "Running LogDash demo..." -echo +# RubyGems serves a version a little after its push, so installing one retries for up to 10 minutes. +attempt=1 +until docker build --no-cache --build-arg LOGDASH_VERSION="${LOGDASH_VERSION:-}" -t logdash-demo -f demo/Dockerfile .; do + if [ -z "${LOGDASH_VERSION:-}" ] || [ "$attempt" -ge 20 ]; then exit 1; fi + echo "logdash $LOGDASH_VERSION does not install yet, retrying in 30s" + attempt=$((attempt + 1)) + sleep 30 +done -# Run in non-interactive mode which works everywhere -docker run --rm \ - -e LOGDASH_API_KEY="${LOGDASH_API_KEY:-YOUR_API_KEY_HERE}" \ - logdash-demo +docker run --rm -e LOGDASH_API_KEY -e LOGDASH_SEED="$seed" logdash-demo -echo -echo "Demo completed!" +# A read-only personal key restricted to the smoke-test domain, which whoami names. +auth="Authorization: Bearer ${LOGDASH_PERSONAL_API_KEY:-}" +if ! whoami=$(curl -sS --fail-with-body -H "$auth" "$api/personal-api-keys/whoami") || + ! domain=$(printf '%s' "$whoami" | jq -er '.access | select(.kind == "clusters" and (.ids | length) == 1) | .ids[0]'); then + access=$(printf '%s' "$whoami" | jq -c '.access' 2>/dev/null || echo 'no access in the response') + echo "LOGDASH_PERSONAL_API_KEY must be a personal API key restricted to the SDK smoke tests domain. Its access: $access" >&2 + exit 1 +fi + +# Logs and metrics show up a few seconds after they arrive. +levels='["debug", "error", "http", "info", "silly", "verbose", "warning"]' +logs='' metrics='' attempt=1 +until + logs=$(curl -sS --fail-with-body -G -H "$auth" --data-urlencode "searchString=$seed" -d apps=ruby-sdk -d limit=100 \ + "$api/clusters/$domain/logs") && + metrics=$(curl -sS --fail-with-body -H "$auth" "$api/clusters/$domain/metrics") && + printf '%s' "$logs" | jq -e --arg m "smoke test $seed" --argjson levels "$levels" \ + '[.[] | select(.app == "ruby-sdk" and .message == $m) | .level] | unique == $levels' >/dev/null && + printf '%s' "$metrics" | jq -e --argjson v "$((seed + 1))" \ + 'any(.[]; .app == "ruby-sdk" and .name == "demo_users" and .value == $v)' >/dev/null +do + if [ "$attempt" -ge 30 ]; then + echo "Did not read back a log of every level and demo_users = $((seed + 1)); both keys must be of one domain." >&2 + echo "Logs: $logs" >&2 + echo "Metrics: $metrics" >&2 + exit 1 + fi + attempt=$((attempt + 1)) + sleep 2 +done +echo "Read back a log of every level and demo_users = $((seed + 1))" diff --git a/lib/logdash/version.rb b/lib/logdash/version.rb index 12dd8db..532ef50 100644 --- a/lib/logdash/version.rb +++ b/lib/logdash/version.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true module Logdash - VERSION = '1.0.2' + # CI writes the released version here before it builds the gem; the v tags are the source of truth + VERSION = '0.0.0' end diff --git a/logdash-1.0.2.gem b/logdash-1.0.2.gem deleted file mode 100644 index 6e43baf..0000000 Binary files a/logdash-1.0.2.gem and /dev/null differ diff --git a/logdash.gemspec b/logdash.gemspec index 9696499..aa1f282 100644 --- a/logdash.gemspec +++ b/logdash.gemspec @@ -18,9 +18,9 @@ Gem::Specification.new do |spec| spec.metadata['homepage_uri'] = spec.homepage spec.metadata['source_code_uri'] = 'https://github.com/logdash-io/ruby-sdk' - spec.metadata['changelog_uri'] = 'https://github.com/logdash-io/ruby-sdk/blob/main/CHANGELOG.md' + spec.metadata['changelog_uri'] = 'https://github.com/logdash-io/ruby-sdk/releases' - spec.files = Dir.glob('lib/**/*.rb') + %w[LICENSE README.md CHANGELOG.md .rubocop.yml] + spec.files = Dir.glob('lib/**/*.rb') + %w[LICENSE README.md] spec.bindir = 'exe' spec.executables = [] spec.require_paths = ['lib'] 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..0319d59 --- /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 gem. CI runs it after each +// merge to main and releases what it prints. Releases are tagged v; the tags are the +// source of truth, lib/logdash/version.rb's version is a placeholder CI overwrites before building. +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) ?? '') +}