Compare commits

...
Author SHA1 Message Date
dependabot[bot] 53fd036f58 build(deps): Bump the github-actions group with 2 updates
Bumps the github-actions group with 2 updates: [QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml](https://github.com/qrun-io/qqq-orb) and [QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml](https://github.com/qrun-io/qqq-orb).


Updates `QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml` from c288a8864fe9054f68410e1d78f5863aea4c4d22 to 1c389210e6dfda1630d053c07ae4dae523dd5576
- [Release notes](https://github.com/qrun-io/qqq-orb/releases)
- [Changelog](https://github.com/QRun-IO/qqq-orb/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/qrun-io/qqq-orb/compare/c288a8864fe9054f68410e1d78f5863aea4c4d22...1c389210e6dfda1630d053c07ae4dae523dd5576)

Updates `QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml` from cc30f599e63c8e41629c99d23395bac1adb8bc44 to 1c389210e6dfda1630d053c07ae4dae523dd5576
- [Release notes](https://github.com/qrun-io/qqq-orb/releases)
- [Changelog](https://github.com/QRun-IO/qqq-orb/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/qrun-io/qqq-orb/compare/cc30f599e63c8e41629c99d23395bac1adb8bc44...1c389210e6dfda1630d053c07ae4dae523dd5576)

---
updated-dependencies:
- dependency-name: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml
  dependency-version: 1c389210e6dfda1630d053c07ae4dae523dd5576
  dependency-type: direct:production
  dependency-group: github-actions
- dependency-name: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml
  dependency-version: 1c389210e6dfda1630d053c07ae4dae523dd5576
  dependency-type: direct:production
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-28 08:25:02 +00:00
james.maesandCodex 33ba265215 ci: adopt managed orb 0.8 patch track (#26)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 15:28:28 -05:00
james.maesandCodex 7a0fd3762a ci: adopt shared orb 0.7.18 (#25)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:46:34 -05:00
james.maesandCodex 2682c2fa94 ci: adopt shared orb 0.7.17 (#24)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:16:09 -05:00
james.maesandCodex 42aaf3c9c8 ci: use the shared branch-aware CodeQL worker (#22)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:43:15 -05:00
james.maesandCodex a40eade67b ci: adopt central orb 0.7.12 and stable gates (#20)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:50:01 -05:00
james.maesandCodex f0b70d7da7 ci: centralize rc release completion in qqq orb (#18)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:23:37 -05:00
james.maesandCodex 4dcabdffed ci: adopt shared orb 0.7.11 (#17)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:57:21 -05:00
james.maes de09fc27e9 Merge pull request #16 from QRun-IO/feature/central-ci
ci: adopt shared QQQ orb 0.7.4
2026-10-20 19:33:40 -05:00
james.maesandCodex fb4ae6db07 ci: adopt central QQQ orb 0.7.4
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 19:26:57 -05:00
james.maes 2f7025ef28 Merge pull request #15 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:03:45 -05:00
james.maesandClaude Opus 5.5 33d993f414 ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:20:23 -05:00
james.maes 87a2218ef6 Merge pull request #14 from QRun-IO/fix/producer-version-from-build
fix: qbit metadata version comes from the build
2026-10-15 01:42:56 -05:00
james.maesandClaude Opus 5.5 56b6f48a7b fix: the qbit metadata version comes from the build, not a hardcoded constant
The producer read a literal version, so released artifacts reported a stale
or -SNAPSHOT version. qbit-slack.properties (version=${project.version}) is
Maven-filtered and the producer loads it; tests assert producer == bundle ==
artifact version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:33:09 -05:00
james.maes 80083044a8 Merge pull request #13 from QRun-IO/fix/health-not-configured
fix(health): unconfigured Slack is UNKNOWN, never DOWN
2026-10-14 23:57:11 -05:00
james.maesandClaude Opus 5.5 1811bbf211 fix(health): an unconfigured Slack app is UNKNOWN, never DOWN
With no SLACK_* secrets at all the app readiness check is NOT_CONFIGURED and
the health indicator reports UNKNOWN (HTTP 200) with configured=false, so an
optional, unset Slack integration can't fail a readiness probe (it took kof22's
probe down). Not-yet-checked is UNKNOWN too. Partial or invalid configuration
stays DOWN. Tests for all three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:54:33 -05:00
james.maes 070e2c95d9 Merge pull request #12 from QRun-IO/fix/help-bundle-version
fix(help): help bundle version comes from the build, not a hardcoded SNAPSHOT
2026-10-14 22:37:20 -05:00
james.maesandClaude Opus 5.5 7f71fc0566 fix(help): the help bundle carries the artifact version (Maven-filtered) instead of a hardcoded SNAPSHOT
On release branches the bundle's version and the producer's differed, so the
bundle test that asserts they match failed, and released bundles recorded
SNAPSHOT as their provenance. help/*.yaml is now filtered:
version: ${project.version}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:10:36 -05:00
15 changed files with 302 additions and 80 deletions

No files matched your search

+10 -61
View File
@@ -1,65 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.9
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
test_only:
setup:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
context: [ qqq-maven-registry-credentials ]
filters:
branches:
ignore: /(develop|release/.*|integration.*)/
tags:
only: []
publish_snapshot:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [ qqq-maven-registry-credentials ]
branch_type: snapshot
filters:
branches:
only:
- develop
publish_release_candidate:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [ qqq-maven-registry-credentials ]
branch_type: release_candidate
filters:
branches:
only:
- /release\/.*/
publish_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [ qqq-maven-registry-credentials ]
branch_type: release
filters:
branches:
ignore: /.*/
tags:
only: [ /^v\d+\.\d+\.\d+$/ ]
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
+62 -13
View File
@@ -1,25 +1,74 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
on:
'on':
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original Circle publisher build number (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types: [closed]
branches: [develop]
types:
- closed
branches:
- develop
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
secrets: inherit
+16
View File
@@ -0,0 +1,16 @@
profile: qbit
orb_version: '0.8'
default_branch: develop
java_version: '21'
browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: stable
test_branch_policy: non-release-no-integration
publish:
snapshot: true
release_candidate: true
release: true
asciidoc: false
release_completion: true
+3
View File
@@ -6,6 +6,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Fixed
- QBit metadata now reports the Maven artifact version, including release versions, from a filtered properties resource.
### Added
- Complete host-neutral in-app help bundle with live labels and core `HelpBundleAssertions` coverage; registered through QQQ 4.1.0-RC.7 `HelpBundles`, replacing the temporary loader.
- "Check channels" action on Registered Channels; readiness now writes the channel's Slack name, private, bot is member, archived, purpose and checked time back to the row.
+5
View File
@@ -430,6 +430,11 @@ user with `users.lookupByEmail`, but only when the IdP marked the session email
results, and a user sees their own in Preferences.
- **Checks run:** every `readinessIntervalMinutes` (15), on the **Check readiness** action, and from
`SlackApi.readiness*`. Messages never include token values or exception text.
- **Not configured is not a failure.** With no Slack app secrets at all, the `app` check is NOT_CONFIGURED and
health is UNKNOWN (HTTP 200) with `configured: false`. Before the first readiness run, health is also UNKNOWN.
Only a configured-but-broken app (some secrets missing, or an invalid token key) is DOWN. Slack is optional, so an
unset Slack integration must never fail a readiness probe; "not configured" shows in the readiness and
central-health views only.
- **Health:** when `qqq-middleware-health` is present, the qbit registers `HealthCheckMetaData` `slack`.
- It reports the stored deployment-level readiness (`app` and `outbox`), without calling Slack on each
probe.
+28
View File
@@ -120,7 +120,35 @@
</dependencies>
<build>
<resources>
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>qbit-slack.properties</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-slack.properties</exclude>
</excludes>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<systemPropertyVariables combine.children="append">
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
@@ -21,8 +21,11 @@
package com.kingsrook.qbits.slack;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import com.kingsrook.qbits.slack.bridge.SlackOutboxMessagingProviderMetaData;
import com.kingsrook.qbits.slack.delivery.SlackAlerts;
import com.kingsrook.qbits.slack.metadata.SlackTables;
@@ -43,13 +46,41 @@ public class SlackQBitProducer implements QBitMetaDataProducer<SlackQBitConfig>
{
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "qbit-slack";
public static final String VERSION = "0.1.0";
public static final String VERSION = loadVersion();
private SlackQBitConfig qBitConfig;
private String namespace;
/*******************************************************************************
** Read the artifact version from the Maven-filtered properties resource.
*******************************************************************************/
private static String loadVersion()
{
try(InputStream inputStream = SlackQBitProducer.class.getClassLoader().getResourceAsStream("qbit-slack.properties"))
{
if(inputStream == null)
{
throw (new IllegalStateException("Missing qbit-slack.properties; build with Maven to supply the artifact version"));
}
Properties properties = new Properties();
properties.load(inputStream);
String version = properties.getProperty("version");
if(version == null || version.isBlank() || version.contains("${"))
{
throw (new IllegalStateException("Missing or unfiltered version in qbit-slack.properties; build with Maven"));
}
return (version);
}
catch(IOException e)
{
throw (new IllegalStateException("Could not read artifact version from qbit-slack.properties", e));
}
}
/*******************************************************************************
** Fluent setter for the config.
*******************************************************************************/
@@ -64,9 +64,29 @@ public class SlackHealth implements HealthIndicator
public HealthCheckResult check(QInstance instance)
{
var readiness = SlackReadiness.stored(instance);
boolean appFailed = readiness.checks().stream().anyMatch(c -> "app".equals(c.name()) && "ERROR".equals(c.status()));
HealthStatus status = appFailed ? HealthStatus.DOWN : readiness.ready() ? HealthStatus.UP : HealthStatus.DEGRADED;
String appStatus = readiness.checks().stream().filter(c -> "app".equals(c.name())).map(c -> c.status()).findFirst().orElse("UNKNOWN");
//////////////////////////////////////////////////////////////////////////////
// Slack is optional. Not configured (no secrets at all) or not yet checked //
// is UNKNOWN, which never fails a readiness probe (HTTP 200). Only a //
// configured-but-broken app (partial or invalid secrets) is DOWN. //
//////////////////////////////////////////////////////////////////////////////
if(SlackReadiness.NOT_CONFIGURED.equals(appStatus))
{
return (new HealthCheckResult().withStatus(HealthStatus.UNKNOWN)
.withDetail("configured", false)
.withDetail("message", "Slack is not configured")
.withDetail("checks", readiness.checks()));
}
if("UNKNOWN".equals(appStatus))
{
return (new HealthCheckResult().withStatus(HealthStatus.UNKNOWN)
.withDetail("message", "Slack readiness has not run yet")
.withDetail("checks", readiness.checks()));
}
HealthStatus status = "ERROR".equals(appStatus) ? HealthStatus.DOWN : readiness.ready() ? HealthStatus.UP : HealthStatus.DEGRADED;
return (new HealthCheckResult().withStatus(status)
.withDetail("configured", true)
.withDetail("checks", readiness.checks()));
}
}
@@ -47,6 +47,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
*******************************************************************************/
public final class SlackReadiness
{
public static final String NOT_CONFIGURED = "NOT_CONFIGURED";
private static final Map<QInstance, SlackApiReadiness> STORED = Collections.synchronizedMap(new WeakHashMap<>());
private static final Map<QInstance, Map<Integer, SlackApiReadiness>> WORKSPACES = java.util.Collections.synchronizedMap(new java.util.WeakHashMap<>());
@@ -87,6 +89,17 @@ public final class SlackReadiness
app = false;
}
List<SlackApiReadinessCheck> checks = new ArrayList<>();
if(!app && !anySecretPresent(config))
{
//////////////////////////////////////////////////////////////////////////
// no SLACK_* secret at all: Slack is simply not set up in this //
// deployment. That is a state, not a failure; health reports UNKNOWN. //
//////////////////////////////////////////////////////////////////////////
checks.add(new SlackApiReadinessCheck("app", NOT_CONFIGURED, "Slack is not configured: no Slack app secrets are set"));
SlackApiReadiness result = new SlackApiReadiness(false, checks);
STORED.put(instance, result);
return (result);
}
checks.add(check("app", app, "Application configuration"));
boolean outbox = true;
if(config != null && QContext.getQInstance() == instance)
@@ -112,6 +125,33 @@ public final class SlackReadiness
return (result);
}
/*******************************************************************************
** True when at least one of the app's secret names resolves to a value.
** Presence only; values are never retained.
*******************************************************************************/
static boolean anySecretPresent(SlackQBitConfig config)
{
if(config == null)
{
return (false);
}
for(String name : java.util.Arrays.asList(config.getClientIdEnvName(), config.getClientSecretEnvName(), config.getSigningSecretEnvName(), config.getTokenKeyEnvName()))
{
try
{
SlackSecrets.get(config, name);
return (true);
}
catch(RuntimeException e)
{
// absent or not allowed; keep looking
}
}
return (false);
}
/*******************************************************************************
** Last scheduled deployment result, without network or database work.
*******************************************************************************/
+1 -1
View File
@@ -1,5 +1,5 @@
bundle: qbit-slack
version: 0.1.0-SNAPSHOT
version: ${project.version}
layer: QBIT
entries:
- key: app:slack;slot:help
+1
View File
@@ -0,0 +1 @@
version=${project.version}
@@ -45,6 +45,8 @@ class SlackQBitProducerTest
SlackQBitProducer producer = new SlackQBitProducer().withQBitConfig(config).withNamespace("ns");
QBitMetaData metaData = producer.getQBitMetaData();
assertThat(System.getProperty("qbit.artifactVersion")).isNotBlank();
assertThat(metaData.getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(SlackQBitProducer.VERSION);
assertThat(metaData.getArtifactId()).isEqualTo("qbit-slack");
assertThat(metaData.getGroupId()).isEqualTo("com.kingsrook.qbits");
assertThat(producer.getNamespace()).isEqualTo("ns");
@@ -33,6 +33,7 @@ import com.kingsrook.qbits.slack.support.SlackStore;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@@ -62,6 +63,58 @@ class SlackReadinessTest
}
}
/*******************************************************************************
** No Slack secrets at all is "not configured": UNKNOWN, never DOWN, so an
** optional, unset Slack integration can't fail a readiness probe.
*******************************************************************************/
@Test
void notConfiguredIsUnknownNotDown() throws Exception
{
try(SlackTestEnv env = environment())
{
env.config().withSecretResolver(n -> null);
assertThat(SlackReadiness.deployment(env.instance()).checks())
.anyMatch(c -> c.name().equals("app") && c.status().equals(SlackReadiness.NOT_CONFIGURED));
var result = new SlackHealth().check(env.instance());
assertThat(result.getStatus().name()).isEqualTo("UNKNOWN");
assertThat(result.getDetails()).containsEntry("configured", false);
}
}
/*******************************************************************************
** Before readiness has run, health is UNKNOWN rather than degraded or down.
*******************************************************************************/
@Test
void notYetCheckedIsUnknown() throws Exception
{
try(SlackTestEnv env = environment())
{
QInstance fresh = new QInstance();
assertThat(new SlackHealth().check(fresh).getStatus().name()).isEqualTo("UNKNOWN");
}
}
/*******************************************************************************
** Partially configured (some secrets set, others missing) is a real
** misconfiguration and stays DOWN.
*******************************************************************************/
@Test
void partiallyConfiguredIsDown() throws Exception
{
try(SlackTestEnv env = environment())
{
env.config().withSecretResolver(n -> "SLACK_CLIENT_ID".equals(n) ? "123.456" : null);
SlackReadiness.deployment(env.instance());
assertThat(new SlackHealth().check(env.instance()).getStatus().name()).isEqualTo("DOWN");
}
}
/*******************************************************************************
** A valid Base64 string still needs exactly 32 decoded bytes.
*******************************************************************************/
@@ -166,7 +219,7 @@ class SlackReadinessTest
}
/*******************************************************************************
** Uninitialized readiness maps to degraded health without network access.
** Uninitialized readiness maps to UNKNOWN health without network access.
*******************************************************************************/
@Test
void notReadyHealth() throws Exception
@@ -174,7 +227,7 @@ class SlackReadinessTest
try(SlackTestEnv env = environment())
{
assertThat(SlackReadiness.stored(env.instance()).ready()).isFalse();
assertThat(new SlackHealth().check(env.instance()).getStatus().name()).isEqualTo("DEGRADED");
assertThat(new SlackHealth().check(env.instance()).getStatus().name()).isEqualTo("UNKNOWN");
assertThat(env.slack().requests()).isEmpty();
}
}
@@ -160,6 +160,8 @@ class SlackTablesGuardrailTest
var bundles = HelpBundles.of(instance).getBundles();
assertThat(bundles).hasSize(1);
assertThat(bundles.get(0).getId()).isEqualTo("qbit-slack");
assertThat(System.getProperty("qbit.artifactVersion")).isNotBlank();
assertThat(bundles.get(0).getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(SlackQBitProducer.VERSION);
for(String table : SlackTables.ALL)
{
assertThat(bundles.get(0).getEntries()).filteredOn(entry -> entry.getKey().equals("table:" + table + ";slot:help"))