From 510542cf0bd7373c3148154cd385e7aee6868eb0 Mon Sep 17 00:00:00 2001 From: "malini-agent[bot]" <338999285+malini-agent[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:27:39 +0200 Subject: [PATCH 1/4] ci: release automatically from conventional commits --- .github/workflows/check-deployed-package.yml | 32 ++++--- .github/workflows/ci.yml | 83 +++++++++++++++++++ .github/workflows/pr-title.yml | 18 ++++ .gitignore | 3 + .travis.yml | 7 -- Gemfile.lock | 2 +- bin/console | 2 +- demo/Dockerfile | 8 +- demo/README.md | 9 +- demo/demo_script.rb | 25 ++---- demo/run.sh | 57 ++++++++++--- lib/logdash/version.rb | 3 +- logdash-1.0.2.gem | Bin 9216 -> 0 bytes logdash.gemspec | 2 +- scripts/version.test.ts | 16 ++++ scripts/version.ts | 26 ++++++ 16 files changed, 236 insertions(+), 57 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/pr-title.yml delete mode 100644 .travis.yml delete mode 100644 logdash-1.0.2.gem create mode 100644 scripts/version.test.ts create mode 100644 scripts/version.ts diff --git a/.github/workflows/check-deployed-package.yml b/.github/workflows/check-deployed-package.yml index 2a19f62..6a3f6b8 100644 --- a/.github/workflows/check-deployed-package.yml +++ b/.github/workflows/check-deployed-package.yml @@ -1,24 +1,30 @@ 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 + 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..6275f05 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,83 @@ +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 + - run: 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: inherit diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..28e547a --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,18 @@ +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] + +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..22d4925 100644 --- a/demo/README.md +++ b/demo/README.md @@ -1,7 +1,12 @@ # 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 project. +`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..b6df3cf 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'), 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..5f97f1a 100755 --- a/demo/run.sh +++ b/demo/run.sh @@ -1,17 +1,50 @@ #!/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 smoke-test project}" +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 project, which whoami names. +auth="Authorization: Bearer ${LOGDASH_PERSONAL_API_KEY:-}" +if ! whoami=$(curl -sS --fail-with-body -H "$auth" "$api/personal-api-keys/whoami") || + ! project=$(printf '%s' "$whoami" | jq -er '.access | select(.kind == "projects" and (.ids | length) == 1) | .ids[0]'); then + echo "LOGDASH_PERSONAL_API_KEY must be a personal API key restricted to the smoke-test project. whoami: $whoami" >&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 limit=100 \ + "$api/projects/$project/logs/v2") && + metrics=$(curl -sS --fail-with-body -H "$auth" "$api/projects/$project/metrics") && + printf '%s' "$logs" | jq -e --arg m "smoke test $seed" --argjson levels "$levels" \ + '[.[] | select(.message == $m) | .level] | unique == $levels' >/dev/null && + printf '%s' "$metrics" | jq -e --argjson v "$((seed + 1))" \ + 'any(.[]; .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 project." >&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 6e43baffd0effb5bf34b1c9659f8e81093d6129e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9216 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 { + 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) ?? '') +} From ff8a8dd20c2fd91f0ddcd4b4b3af7dff163de20f Mon Sep 17 00:00:00 2001 From: "malini-agent[bot]" <338999285+malini-agent[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:58:56 +0200 Subject: [PATCH 2/4] =?UTF-8?q?Make=20the=20Ruby=20SDK=20(RubyGems=20`logd?= =?UTF-8?q?ash`)=20release=20automatically=20from=20Conventional=20Commits?= =?UTF-8?q?=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- demo/README.md | 3 ++- demo/demo_script.rb | 2 +- demo/run.sh | 21 +++++++++++---------- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/demo/README.md b/demo/README.md index 22d4925..effa3f4 100644 --- a/demo/README.md +++ b/demo/README.md @@ -7,6 +7,7 @@ CI runs it after each release, and the `Check deployed package` workflow runs it 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 project. +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 b6df3cf..a8b0115 100755 --- a/demo/demo_script.rb +++ b/demo/demo_script.rb @@ -6,7 +6,7 @@ seed = ENV.fetch('LOGDASH_SEED') puts "=== LogDash SDK Demo: logdash #{Logdash::VERSION}, seed #{seed} ===" -logdash = Logdash.create(api_key: ENV.fetch('LOGDASH_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] diff --git a/demo/run.sh b/demo/run.sh index 5f97f1a..4da6a70 100755 --- a/demo/run.sh +++ b/demo/run.sh @@ -3,7 +3,7 @@ # and reads them back from the API. Needs Docker, curl and jq. set -eu -: "${LOGDASH_API_KEY:?must be the ingest key of the smoke-test project}" +: "${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 @@ -18,11 +18,12 @@ done docker run --rm -e LOGDASH_API_KEY -e LOGDASH_SEED="$seed" logdash-demo -# A read-only personal key restricted to the smoke-test project, which whoami names. +# 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") || - ! project=$(printf '%s' "$whoami" | jq -er '.access | select(.kind == "projects" and (.ids | length) == 1) | .ids[0]'); then - echo "LOGDASH_PERSONAL_API_KEY must be a personal API key restricted to the smoke-test project. whoami: $whoami" >&2 + ! 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 @@ -30,16 +31,16 @@ fi 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 limit=100 \ - "$api/projects/$project/logs/v2") && - metrics=$(curl -sS --fail-with-body -H "$auth" "$api/projects/$project/metrics") && + 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(.message == $m) | .level] | unique == $levels' >/dev/null && + '[.[] | select(.app == "ruby-sdk" and .message == $m) | .level] | unique == $levels' >/dev/null && printf '%s' "$metrics" | jq -e --argjson v "$((seed + 1))" \ - 'any(.[]; .name == "demo_users" and .value == $v)' >/dev/null + '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 project." >&2 + 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 From feba0045e7507c27872b6c98760da154499caefd Mon Sep 17 00:00:00 2001 From: "malini-agent[bot]" <338999285+malini-agent[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:27:13 +0200 Subject: [PATCH 3/4] =?UTF-8?q?Make=20the=20Ruby=20SDK=20(RubyGems=20`logd?= =?UTF-8?q?ash`)=20release=20automatically=20from=20Conventional=20Commits?= =?UTF-8?q?=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/check-deployed-package.yml | 5 +++++ .github/workflows/ci.yml | 7 +++++-- .github/workflows/pr-title.yml | 2 ++ logdash.gemspec | 2 +- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/check-deployed-package.yml b/.github/workflows/check-deployed-package.yml index 6a3f6b8..b7a00e7 100644 --- a/.github/workflows/check-deployed-package.yml +++ b/.github/workflows/check-deployed-package.yml @@ -8,6 +8,11 @@ on: version: type: string required: true + secrets: + LOGDASH_API_KEY: + required: true + LOGDASH_PERSONAL_API_KEY: + required: true workflow_dispatch: inputs: version: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6275f05..082c6e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,7 +68,8 @@ jobs: - run: sed -i "s/VERSION = '.*'/VERSION = '$V'/" lib/logdash/version.rb - run: gem build logdash.gemspec - uses: rubygems/configure-rubygems-credentials@v2.1.0 - - run: gem push "logdash-$V.gem" + # 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: @@ -80,4 +81,6 @@ jobs: uses: ./.github/workflows/check-deployed-package.yml with: version: ${{ needs.version.outputs.next }} - secrets: inherit + 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 index 28e547a..06689c4 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -5,6 +5,8 @@ on: pull_request: types: [opened, edited, reopened, synchronize] +permissions: {} + jobs: conventional: runs-on: ubuntu-latest diff --git a/logdash.gemspec b/logdash.gemspec index f83e417..aa1f282 100644 --- a/logdash.gemspec +++ b/logdash.gemspec @@ -20,7 +20,7 @@ Gem::Specification.new do |spec| spec.metadata['source_code_uri'] = 'https://github.com/logdash-io/ruby-sdk' 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'] From e2cefe641bc4ce94de8b167a10e9ed73acb7f851 Mon Sep 17 00:00:00 2001 From: "malini-agent[bot]" <338999285+malini-agent[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:51:46 +0200 Subject: [PATCH 4/4] =?UTF-8?q?Make=20the=20Ruby=20SDK=20(RubyGems=20`logd?= =?UTF-8?q?ash`)=20release=20automatically=20from=20Conventional=20Commits?= =?UTF-8?q?=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/approval.yml | 45 ++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/approval.yml 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