Compare commits

...
Author SHA1 Message Date
james.maesandCodex 571f14ff82 ci: use the shared branch-aware CodeQL worker
Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 10:24:57 -05:00
james.maesandCodex 0ce28313f9 ci: adopt central orb 0.7.12 and stable gates (#24)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:52:48 -05:00
james.maesandCodex a8980eec0f ci: centralize rc release completion in qqq orb (#23)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:24:23 -05:00
james.maesandCodex 082f3b95f0 ci: adopt shared orb 0.7.11 (#22)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:56:21 -05:00
james.maes c681b2c0e7 Merge pull request #21 from QRun-IO/feature/central-ci
ci: adopt shared QQQ orb standard
2026-10-20 19:21:02 -05:00
james.maesandCodex 558effe85f ci: adopt verified central QQQ orb 0.7.3
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 19:07:42 -05:00
james.maes 93f6f86f2d Merge pull request #18 from QRun-IO/feature/clearer-alert-titles
fix(alerts): stable chain break alert title
2026-10-19 10:05:18 -05:00
james.maes 7e15fb92e0 Merge pull request #20 from QRun-IO/feature/embedded-audit-app
feat: allow host-owned Audit app composition
2026-10-19 10:05:14 -05:00
james.maesandCodex a5d7192b95 feat: allow host-owned Audit app composition
Keep standalone defaults while permitting embedded WORM metadata and authorized widgets without replacing host navigation or help.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-19 10:01:20 -05:00
james.maesandClaude Opus 5.5 05fcbdd8b8 fix(alerts): stable chain break alert title
The wormaudit.chain.break title was the first break reason, so the bell text
changed on every run. The title is now "Audit chain verification found N
problems"; the first reasons move to the summary. Dedupe key and severity are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:59 -05:00
james.maes d78ecc1bc4 Merge pull request #17 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:04:06 -05:00
james.maesandClaude Opus 5.5 e81c8ba37e 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:12 -05:00
12 changed files with 220 additions and 79 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.7.12
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.7.12
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@c288a8864fe9054f68410e1d78f5863aea4c4d22
+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@cc30f599e63c8e41629c99d23395bac1adb8bc44
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.7.12
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
+6
View File
@@ -137,6 +137,12 @@ Open `/app/worm_audit` (or `/app/admin/worm_audit` with `parentAppName="admin"`)
The full-width `wormAuditHome` HTML widget uses the standard `/qqq/v1/widget` API.
Hosts can add `wormAuditHealthCard` to their own dashboard. Both widgets and the app
require read permission on at least one WORM table, using the host's table permission rules.
To embed WORM in an existing host app, set `withIncludeApp(false)` and configure
`appName`, `appLabel` and optional `parentAppName` for that host app. The host owns
its navigation, home widget placement and app help; the qbit does not create or replace
the app or fill its help. Both widgets and all table help, permissions, guards,
processes and schedules remain registered. The health card links to the configured
host app. Standalone app creation remains enabled by default.
The aggregate home shows only identifiers, counts, times, shortened hashes and reasons;
it never renders audit messages or field values. Record links retain the target table's permissions.
@@ -48,6 +48,7 @@ public class WormAuditQBitConfig implements QBitConfig
private String exportLockMode = "GOVERNANCE";
private int exportRetentionDays = 30;
private final WormAuditSettings settings = new WormAuditSettings(this);
private boolean includeApp = true;
private String appName = "worm_audit";
private String appLabel = "WORM Audit";
private String parentAppName;
@@ -131,7 +132,26 @@ public class WormAuditQBitConfig implements QBitConfig
/*******************************************************************************
** Read the app name.
** Whether to create the standalone app. Widgets remain available when false.
*******************************************************************************/
public boolean getIncludeApp()
{
return (includeApp);
}
/*******************************************************************************
** Disable standalone app creation when the host owns the Audit app and help.
*******************************************************************************/
public WormAuditQBitConfig withIncludeApp(boolean includeApp)
{
this.includeApp = includeApp;
return (this);
}
/*******************************************************************************
** Read the app name (the host-owned destination when includeApp is false).
*******************************************************************************/
public String getAppName()
{
@@ -214,7 +214,10 @@ public class WormAuditQBitProducer implements QBitMetaDataProducer<WormAuditQBit
{
qInstance.addScheduler(new QuartzSchedulerMetaData().withName(qBitConfig.getSchedulerName()));
}
output.add(WormAuditApp.app(qBitConfig, producedTables));
if(qBitConfig.getIncludeApp())
{
output.add(WormAuditApp.app(qBitConfig, producedTables));
}
for(QWidgetMetaData widget : WormAuditApp.widgets())
{
output.add(widget);
@@ -115,7 +115,7 @@ public final class WormAuditHelp
table.withHelpContent(SLOT, content(entry.getValue()));
}
}
else if(key.startsWith("app:"))
else if(config.getIncludeApp() && key.startsWith("app:"))
{
QAppMetaData app = qInstance.getApp(config.getAppName());
if(app != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
@@ -24,6 +24,7 @@ package com.kingsrook.qbits.wormaudit.processes;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import com.kingsrook.qbits.wormaudit.WormAuditQBitConfig;
import com.kingsrook.qbits.wormaudit.chain.AuditStore;
import com.kingsrook.qbits.wormaudit.model.WormAuditTables;
@@ -43,6 +44,11 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
*******************************************************************************/
public final class VerificationRuns
{
/** How many break reasons the alert summary spells out; the title carries the total. */
private static final int ALERT_SUMMARY_REASONS = 3;
/*******************************************************************************
** Utility class.
*******************************************************************************/
@@ -79,8 +85,15 @@ public final class VerificationRuns
}
else
{
long problems = result.getBreakCount();
String summary = result.getBreaks().stream().limit(ALERT_SUMMARY_REASONS).map(b -> b.reason()).collect(Collectors.joining("; "));
if(problems > ALERT_SUMMARY_REASONS)
{
summary += "; and " + (problems - ALERT_SUMMARY_REASONS) + " more";
}
QAlerts.raise(new AlertSignal("wormaudit.chain.break").withDedupKey("chain")
.withTitle(result.getBreaks().getFirst().reason()).withRecordRef(new AlertRecordRef(WormAuditTables.VERIFICATION, row.getValue("id"))));
.withTitle("Audit chain verification found " + problems + (problems == 1 ? " problem" : " problems")).withSummary(summary)
.withRecordRef(new AlertRecordRef(WormAuditTables.VERIFICATION, row.getValue("id"))));
}
}
return (result);
@@ -142,7 +142,8 @@ class ProcessesAndAlertsTest
assertThat(Sink.raised.getFirst().getDedupKey()).isEqualTo("chain");
assertThat(Sink.raised.getFirst().getRecordRefs().getFirst().getTableName())
.isEqualTo(WormAuditTables.VERIFICATION);
assertThat(Sink.raised.getFirst().getTitle()).contains("Break at link 1");
assertThat(Sink.raised.getFirst().getTitle()).isEqualTo("Audit chain verification found 1 problem");
assertThat(Sink.raised.getFirst().getSummary()).contains("Break at link 1");
var first = STORE.all(WormAuditTables.VERIFICATION).getFirst();
assertThat(first.getValueLong("first_break_link_id")).isEqualTo(1);
assertThat(first.getValueLong("first_break_audit_id")).isEqualTo(1);
@@ -278,6 +278,29 @@ class WormAuditHomeTest
}
/*******************************************************************************
** Embedded widgets retain direct authorization without a standalone app.
*******************************************************************************/
@Test
void embeddedWidgetsRequireReadPermission() throws Exception
{
var config = ChainVerificationTest.signed().withIncludeApp(false).withAppName("audit").withAppLabel("Audit");
var instance = init(config);
assertThat(instance.getApp("worm_audit")).isNull();
assertThat(instance.getApp("audit")).isNull();
var input = new RenderWidgetInput().withWidgetMetaData(instance.getWidget(WormAuditApp.HEALTH_CARD_WIDGET));
QContext.getQSession().withPermissions("admin.hasAccess");
assertThatThrownBy(() -> new WormAuditApp.HealthCardRenderer().render(input)).isInstanceOf(QPermissionDeniedException.class);
assertThatThrownBy(() -> new WormAuditApp.HomeRenderer().render(input)).isInstanceOf(QPermissionDeniedException.class);
QContext.getQSession().withPermissions(WormAuditTables.CHAIN + ".read");
String card = ((RawHTML) new WormAuditApp.HealthCardRenderer().render(input).getWidgetData()).getHtml();
assertThat(card).contains("href=\"/app/audit\"");
assertThat(new WormAuditApp.HomeRenderer().render(input).getWidgetData()).isInstanceOf(RawHTML.class);
QContext.setQSession(new QSession());
assertThatThrownBy(() -> new WormAuditApp.HealthCardRenderer().render(input)).isInstanceOf(QPermissionDeniedException.class);
}
/*******************************************************************************
** Exercise the real SQL anti-join without Docker, including its exact boundary.
*******************************************************************************/
@@ -21,10 +21,15 @@
package com.kingsrook.qbits.wormaudit;
import java.util.List;
import com.kingsrook.qbits.wormaudit.app.WormAuditApp;
import com.kingsrook.qbits.wormaudit.model.WormAuditHelp;
import com.kingsrook.qbits.wormaudit.ddl.WormAuditDdl;
import com.kingsrook.qbits.wormaudit.model.WormAuditTables;
import com.kingsrook.qbits.wormaudit.processes.WormAuditProcesses;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@@ -43,6 +48,7 @@ class WormAuditQBitProducerTest
{
var config = new WormAuditQBitConfig();
QInstance instance = WormAuditTestSupport.init(config);
assertThat(config.getIncludeApp()).isTrue();
assertThat(config.getRetentionYears()).isEqualTo(6);
assertThat(config.getTableMetaDataCustomizer()).isNull();
assertThat(config.getSigningKey()).isNull();
@@ -160,4 +166,36 @@ class WormAuditQBitProducerTest
() -> WormAuditDdl.resolveNames(new WormAuditQBitConfig(), new QInstance()))
.isInstanceOf(IllegalArgumentException.class);
}
/*******************************************************************************
** Embedded production keeps the host app intact and registers all evidence metadata.
*******************************************************************************/
@Test
void embeddedMetadataPreservesHostApp() throws Exception
{
var config = new WormAuditQBitConfig().withBackendName("memory").withIncludeApp(false).withAppName("audit");
var instance = WormAuditTestSupport.core(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
var host = new QAppMetaData().withName("audit").withLabel("Combined Audit").withWidgets(List.of("hostSummary"));
instance.addApp(host);
new WormAuditQBitProducer().withQBitConfig(config).produce(instance);
assertThat(config.getIncludeApp()).isFalse();
assertThat(instance.getApp("worm_audit")).isNull();
assertThat(instance.getApp("audit")).isSameAs(host);
assertThat(host.getLabel()).isEqualTo("Combined Audit");
assertThat(host.getWidgets()).containsExactly("hostSummary");
assertThat(host.getHelpContent()).isNullOrEmpty();
assertThat(instance.getWidget(WormAuditApp.HEALTH_CARD_WIDGET)).isNotNull();
assertThat(instance.getWidget(WormAuditApp.HOME_WIDGET)).isNotNull();
for(String name : WormAuditTables.ALL)
{
assertThat(instance.getTable(name).getHelpContent()).containsKey(WormAuditHelp.SLOT);
assertThat(instance.getTable(name).getPermissionRules().getPermissionBaseName()).isEqualTo(name);
}
assertThat(instance.getTable(WormAuditTables.CHAIN).getDisabledCapabilities()).contains(Capability.TABLE_UPDATE, Capability.TABLE_DELETE, Capability.TABLE_INSERT);
assertThat(instance.getTable("audit").getDisabledCapabilities()).contains(Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
assertThat(instance.getProcess(WormAuditProcesses.VERIFY)).isNotNull();
assertThat(instance.getProcess(WormAuditProcesses.CHAIN).getSchedule().getRepeatSeconds()).isEqualTo(config.getChainIntervalSeconds());
assertThat(instance.getProcess(WormAuditProcesses.DAILY).getSchedule().getCronExpression()).isEqualTo(config.getVerifyCron());
}
}