Compare commits

...
Author SHA1 Message Date
BaaRai 01a9dae49e test(db): make profile and company-scope tests repeatable on one database
TestPoolProfilesOrder, TestProfileRetryRoundTrip and
TestProfileRetryClampedOnSave closed the pool with defer d.Close(), which
runs before t.Cleanup, so the row-deleting cleanup hit a closed pool and its
error was discarded. The fixed profile names then collided with the
llm_profiles name unique key on the next run. Close via t.Cleanup registered
first, give the profiles per-run names, and report cleanup errors.
TestLLMRetryPolicyRoundTrip had the same ordering and now restores the
setting before the pool closes.

TestCompanyScopeAttribution derived its names from MAX(assets.id) and never
removed its company, so the next pass found the old company holding the same
fixed 198.51.100.0/24 scope and the in-CIDR IP was attributed to it. Use a
time plus random suffix and a per-run /24 from 198.18.0.0/15, delete the
company (its scope cascades) after the assets, and report cleanup errors.
2026-11-13 23:46:42 +01:00
BaaRai 4e126a8524 Merge pull request #219 from BaaRai/feat/release-provenance
release: provenance attestation, CycloneDX SBOM and dry-run trigger
2026-11-13 23:28:15 +01:00
BaaRai abb6cb177d release: add build provenance attestation, CycloneDX SBOM and a dry-run trigger
- release job: attest dist/*.zip with actions/attest-build-provenance
  (id-token: write, attestations: write; contents stays write for the Release).
- SBOM: syft scans the bin-linux-amd64 binary (embedded Go modules), plus
  web/package-lock.json and go.mod/go.sum; artex-<tag>.cdx.json is checked for
  pkg:golang and pkg:npm entries and attached next to SHA256SUMS.
- workflow_dispatch dry run: builds, SBOM, attestations and workflow artifacts;
  the Release step and the docker job only run on push.
- README: Verifying a release section.
2026-11-13 23:17:19 +01:00
BaaRai 5122baa77f Merge pull request #218 from BaaRai/feat/secrets-phase3b-evidence
secrets: encrypt the evidence snapshot heads at rest (phase 3b)
2026-11-13 23:11:53 +01:00
5 changed files with 117 additions and 24 deletions
+43 -1
View File
@@ -2,6 +2,9 @@ name: release
on:
push:
tags: ["v*"]
# Dry run: builds, SBOM, attestations and workflow artifacts only. The GitHub
# Release and the image push are gated on github.event_name == 'push'.
workflow_dispatch:
permissions:
contents: write # required to create the Release
@@ -81,6 +84,10 @@ jobs:
release:
needs: binaries
runs-on: ubuntu-latest
permissions:
contents: write # create the Release (softprops/action-gh-release)
id-token: write # Sigstore signing of the provenance attestation
attestations: write # store the attestation on the repository
steps:
- uses: actions/checkout@v4 # CHANGELOG.md and the extractor script
- uses: actions/download-artifact@v4
@@ -91,6 +98,39 @@ jobs:
- name: Generate checksums
working-directory: dist
run: sha256sum *.zip > SHA256SUMS
# SBOM of what ships: the Go modules embedded in the linux/amd64 binary, plus
# the frontend lockfile and the Go module files.
- uses: actions/download-artifact@v4
with:
name: bin-linux-amd64
path: ${{ runner.temp }}/sbom-src/bin
- uses: anchore/sbom-action/download-syft@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
id: syft
with:
syft-version: v1.54.0
- name: Generate CycloneDX SBOM
env:
SYFT: ${{ steps.syft.outputs.cmd }}
SRC: ${{ runner.temp }}/sbom-src
TAG: ${{ github.ref_name }}
run: |
mkdir -p "$SRC/web"
cp go.mod go.sum "$SRC/"
cp web/package-lock.json "$SRC/web/"
sbom="dist/artex-${TAG}.cdx.json"
"$SYFT" scan "dir:$SRC" --source-name artex --source-version "$TAG" -o "cyclonedx-json=$sbom"
grep -q '"pkg:golang/' "$sbom" || { echo "::error::SBOM has no pkg:golang entries"; exit 1; }
grep -q '"pkg:npm/' "$sbom" || { echo "::error::SBOM has no pkg:npm entries"; exit 1; }
echo "SBOM_PATH=$sbom" >> "$GITHUB_ENV"
- uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: dist/*.zip
- uses: actions/upload-artifact@v4
with:
name: release-metadata
path: |
dist/SHA256SUMS
${{ env.SBOM_PATH }}
- name: Release body from CHANGELOG.md
env:
TAG: ${{ github.ref_name }}
@@ -103,10 +143,12 @@ jobs:
echo "Release $TAG. See CHANGELOG.md for the list of changes in this release." > "$RUNNER_TEMP/release-body.md"
fi
- uses: softprops/action-gh-release@v2
if: github.event_name == 'push'
with:
files: |
dist/*.zip
dist/SHA256SUMS
${{ env.SBOM_PATH }}
generate_release_notes: false
body_path: ${{ runner.temp }}/release-body.md
@@ -118,7 +160,7 @@ jobs:
# and provide DOCKERHUB_USERNAME / DOCKERHUB_TOKEN secrets plus DOCKERHUB_IMAGE).
docker:
needs: binaries
if: ${{ vars.DOCKERHUB_ENABLED == 'true' }}
if: ${{ vars.DOCKERHUB_ENABLED == 'true' && github.event_name == 'push' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # fetch the Dockerfile and skills/
+9
View File
@@ -134,6 +134,15 @@ cp config.example.json config.json # fill in the database connection
> Please start with `start.sh` / `start.bat` instead of running `./artex` directly. It is a daemon script: if the process crashes, it restarts it after a backoff.
> To keep it running in the background: `nohup ./start.sh >artex.log 2>&1 &`.
#### Verifying a release
Each release attaches `SHA256SUMS` and a CycloneDX SBOM, `artex-<tag>.cdx.json` (Go modules embedded in the linux/amd64 binary, the frontend `package-lock.json`, and `go.mod`/`go.sum`), next to the zips. The zips also carry a GitHub build provenance attestation. Releases published before this was added have neither the SBOM nor the attestation.
```bash
sha256sum -c SHA256SUMS # add --ignore-missing if you downloaded only some zips
gh attestation verify <zip> --repo BaaRai/ARTEX # provenance: built by this repository's release workflow
```
### Option 4: build the single binary from source
```bash
+26 -8
View File
@@ -2,6 +2,7 @@ package db
import (
"fmt"
"math/rand/v2"
"testing"
"time"
)
@@ -331,19 +332,36 @@ func TestCompanyScopeAttribution(t *testing.T) {
d.Close()
t.Fatalf("startMax: %v", err)
}
// Remove everything the test creates, or the next pass on the same database
// finds this company's scope and attributes its assets to it. Assets first
// (they reference the company), then the company; its scope rows cascade.
var cid int64
t.Cleanup(func() {
_, _ = d.Exec(`DELETE FROM assets WHERE id > $1`, startMax)
d.Close()
defer d.Close()
if _, err := d.Exec(`DELETE FROM assets WHERE id > $1`, startMax); err != nil {
t.Errorf("cleanup assets: %v", err)
}
if cid != 0 {
if _, err := d.Exec(`DELETE FROM companies WHERE id = $1`, cid); err != nil {
t.Errorf("cleanup company %d: %v", cid, err)
}
}
})
uniq := startMax + 1
root := fmt.Sprintf("scopetest%d.com", uniq)
// Unique per run: MAX(assets.id) comes back to the same value after cleanup,
// so it cannot tell one pass from the next. The in-scope /24 is picked per
// run from 198.18.0.0/15 so a scope left by an interrupted run cannot claim
// this run's IP.
uniq := fmt.Sprintf("%d%d", time.Now().UnixNano(), rand.Uint32())
n := rand.IntN(512)
cidr := fmt.Sprintf("198.%d.%d.0/24", 18+n/256, n%256)
root := fmt.Sprintf("scopetest%s.com", uniq)
sub := "api." + root
ipIn := "198.51.100.9"
ipIn := fmt.Sprintf("198.%d.%d.9", 18+n/256, n%256)
ipOut := "203.0.113.9"
outDomain := fmt.Sprintf("other%d.net", uniq)
outDomain := fmt.Sprintf("other%s.net", uniq)
cid, _, err := cs.UpsertCompany(fmt.Sprintf("ScopeCo %d", uniq), "")
cid, _, err = cs.UpsertCompany(fmt.Sprintf("ScopeCo %s", uniq), "")
if err != nil {
t.Fatalf("UpsertCompany: %v", err)
}
@@ -359,7 +377,7 @@ func TestCompanyScopeAttribution(t *testing.T) {
t.Fatalf("pre-scope asset should be unattributed, got %v", *preCompanyID)
}
cs.AddScope(cid, []string{root, "198.51.100.0/24"}, "unit test")
cs.AddScope(cid, []string{root, cidr}, "unit test")
mustCid := func(id int64, want int64, label string) {
var cID *int64
+22 -6
View File
@@ -4,7 +4,9 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"testing"
"time"
)
// TestPoolProfilesOrder pins the failover chain query: keyless profiles can't
@@ -17,17 +19,22 @@ func TestPoolProfilesOrder(t *testing.T) {
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
defer d.Close()
// Close via Cleanup, registered FIRST so it runs LAST: a plain defer would
// close the pool before the row-deleting cleanups below run.
t.Cleanup(func() { d.Close() })
// llm_profiles.name is unique, so a fixed name collides with a row left on
// the same database by an earlier run.
sfx := fmt.Sprintf("-%d", time.Now().UnixNano())
mk := func(name string, priority int, exclude bool, key string) int64 {
id, err := d.SaveProfile(&LLMProfile{
Name: name, Format: "openai", Model: "m", APIKey: key,
Name: name + sfx, Format: "openai", Model: "m", APIKey: key,
Priority: priority, PoolExclude: exclude,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Exec(`DELETE FROM llm_profiles WHERE id=$1`, id) })
t.Cleanup(func() { deleteTestProfile(t, d, id) })
return id
}
lo := mk("t-pool-lo", 1, false, "k1")
@@ -42,11 +49,11 @@ func TestPoolProfilesOrder(t *testing.T) {
var got []int64
for _, p := range chain {
switch p.Name {
case "t-pool-lo", "t-pool-hi":
case "t-pool-lo" + sfx, "t-pool-hi" + sfx:
got = append(got, p.ID)
case "t-pool-excluded":
case "t-pool-excluded" + sfx:
t.Fatal("pool_exclude profile entered the failover chain")
case "t-pool-nokey":
case "t-pool-nokey" + sfx:
t.Fatal("keyless profile entered the failover chain")
}
}
@@ -55,6 +62,15 @@ func TestPoolProfilesOrder(t *testing.T) {
}
}
// deleteTestProfile removes a profile a test created and reports a failed delete
// instead of silently leaving the row for the next run.
func deleteTestProfile(t *testing.T, d *DB, id int64) {
t.Helper()
if _, err := d.Exec(`DELETE FROM llm_profiles WHERE id=$1`, id); err != nil {
t.Errorf("cleanup profile %d: %v", id, err)
}
}
func TestDeleteProfileContextHonorsCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
+17 -9
View File
@@ -1,6 +1,7 @@
package db
import (
"fmt"
"testing"
"time"
)
@@ -14,7 +15,8 @@ func TestProfileRetryRoundTrip(t *testing.T) {
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
defer d.Close()
// Registered FIRST so it runs LAST, after the row cleanup below.
t.Cleanup(func() { d.Close() })
want := RetryOverride{
Connect: RetryRule{Attempts: 5, IntervalMS: 2000},
@@ -22,12 +24,12 @@ func TestProfileRetryRoundTrip(t *testing.T) {
Stream: RetryRule{IntervalMS: 250},
}
id, err := d.SaveProfile(&LLMProfile{
Name: "t-retry-roundtrip", Format: "openai", Model: "m", APIKey: "k", Retry: want,
Name: fmt.Sprintf("t-retry-roundtrip-%d", time.Now().UnixNano()), Format: "openai", Model: "m", APIKey: "k", Retry: want,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Exec(`DELETE FROM llm_profiles WHERE id=$1`, id) })
t.Cleanup(func() { deleteTestProfile(t, d, id) })
got, err := d.ProfileByID(id)
if err != nil || got == nil {
@@ -71,16 +73,17 @@ func TestProfileRetryClampedOnSave(t *testing.T) {
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
defer d.Close()
// Registered FIRST so it runs LAST, after the row cleanup below.
t.Cleanup(func() { d.Close() })
id, err := d.SaveProfile(&LLMProfile{
Name: "t-retry-clamp", Format: "openai", Model: "m", APIKey: "k",
Name: fmt.Sprintf("t-retry-clamp-%d", time.Now().UnixNano()), Format: "openai", Model: "m", APIKey: "k",
Retry: RetryOverride{Connect: RetryRule{Attempts: -99, IntervalMS: -5}},
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Exec(`DELETE FROM llm_profiles WHERE id=$1`, id) })
t.Cleanup(func() { deleteTestProfile(t, d, id) })
got, err := d.ProfileByID(id)
if err != nil || got == nil {
t.Fatalf("ProfileByID: %v", err)
@@ -97,17 +100,22 @@ func TestLLMRetryPolicyRoundTrip(t *testing.T) {
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
defer d.Close()
// Registered FIRST so it runs LAST, after the restore below.
t.Cleanup(func() { d.Close() })
before, hadBefore, err := d.GetSetting(settingLLMRetryPolicy)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
var err error
if hadBefore {
d.SetSetting(settingLLMRetryPolicy, before)
err = d.SetSetting(settingLLMRetryPolicy, before)
} else {
d.Exec(`DELETE FROM settings WHERE key=$1`, settingLLMRetryPolicy)
_, err = d.Exec(`DELETE FROM settings WHERE key=$1`, settingLLMRetryPolicy)
}
if err != nil {
t.Errorf("restore %s: %v", settingLLMRetryPolicy, err)
}
})