6 Commits

Author SHA1 Message Date
nkanaev
6db9a4b556 util: strip v prefix from version number in versioninfo generator 2026-06-24 14:48:51 +01:00
nkanaev
c90c40aba1 util: change versioning logic 2026-06-24 14:22:54 +01:00
nkanaev
41faa8c088 doc: update changelog 2026-06-24 14:03:37 +01:00
nkanaev
c447372fe2 ci: bleeding-edge docker releases 2026-06-24 13:42:15 +01:00
nkanaev
2f39fcc6f6 ci: update workflows 2026-06-23 13:52:53 +01:00
nkanaev
21c7f9a4a4 storage: test postgres via docker 2026-06-23 13:44:10 +01:00
10 changed files with 118 additions and 76 deletions

View File

@@ -3,6 +3,8 @@ on:
push: push:
tags: tags:
- v* - v*
branches:
- master
workflow_dispatch: workflow_dispatch:
env: env:
@@ -37,6 +39,11 @@ jobs:
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=schedule
type=ref,event=branch
type=ref,event=tag
type=raw,value=bleeding,enable=${{ github.ref_name == 'master' }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6

View File

@@ -12,11 +12,11 @@ jobs:
runs-on: macos-13 runs-on: macos-13
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v4 uses: actions/setup-go@v6
with: with:
go-version: '^1.23' go-version-file: 'go.mod'
- name: Build arm64 gui - name: Build arm64 gui
uses: ./.github/actions/prepare uses: ./.github/actions/prepare
with: with:
@@ -47,11 +47,11 @@ jobs:
runs-on: windows-2022 runs-on: windows-2022
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v4 uses: actions/setup-go@v6
with: with:
go-version: '^1.23' go-version-file: 'go.mod'
- name: Build amd64 gui - name: Build amd64 gui
uses: ./.github/actions/prepare uses: ./.github/actions/prepare
with: with:
@@ -71,11 +71,11 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v4 uses: actions/setup-go@v6
with: with:
go-version: '^1.23' go-version-file: 'go.mod'
- name: Setup Zig - name: Setup Zig
uses: mlugg/setup-zig@v1 uses: mlugg/setup-zig@v1
with: with:

View File

@@ -6,14 +6,17 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
YARR_POSTGRES_TEST_IMAGE: postgres:17-alpine
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v4 uses: actions/setup-go@v6
with: with:
go-version: '^1.18' go-version-file: 'go.mod'
- name: Run tests - name: Run tests
run: make test run: make test

View File

@@ -73,7 +73,7 @@ func main() {
flag.Parse() flag.Parse()
if ver { if ver {
fmt.Printf("v%s (%s)\n", Version, GitHash) fmt.Printf("%s (%s)\n", Version, GitHash)
return return
} }

View File

@@ -1,10 +1,14 @@
# upcoming # upcoming
- (new) initial PostgreSQL support
- (new) i18n: English, Chinese, French, German, Japanese, Portuguese, Russian, Spanish
- (fix) articles not resetting immediately after feed/filter selection (thank to @scratchmex for the report) - (fix) articles not resetting immediately after feed/filter selection (thank to @scratchmex for the report)
- (fix) crash on empty article list with article is selected (thanks to @rksvc) - (fix) crash on empty article list with article is selected (thanks to @rksvc)
- (fix) invalid article title in RSS feeds with media containing titles (thanks to @bwwu-git for the report) - (fix) invalid article title in RSS feeds with media containing titles (thanks to @bwwu-git for the report)
- (fix) missing image enclosures in certain RSS feeds (thanks to @palinek for the report) - (fix) missing image enclosures in certain RSS feeds (thanks to @palinek for the report)
- (fix) parsing namespaced legacy RSS feeds (thanks to @f100024) - (fix) parsing namespaced legacy RSS feeds (thanks to @f100024)
- (fix) marking feeds read in Fever API (thanks to @weskoop)
- (etc) systray improvements for macOS
# v2.6 (2025-11-24) # v2.6 (2025-11-24)

View File

@@ -51,8 +51,9 @@ while [[ $# -gt 0 ]]; do
esac esac
done done
# Replace dots with commas for version_comma # Strip leading 'v' and replace dots with commas for version_comma
version_comma="${version//./,}" version_num="${version#v}"
version_comma="${version_num//./,}"
# Use a here document for the template with ENDFILE delimiter # Use a here document for the template with ENDFILE delimiter
cat <<ENDFILE > "$outfile" cat <<ENDFILE > "$outfile"

4
go.mod
View File

@@ -1,8 +1,6 @@
module github.com/nkanaev/yarr module github.com/nkanaev/yarr
go 1.23.0 go 1.25.0
toolchain go1.23.5
require ( require (
fyne.io/systray v1.12.0 fyne.io/systray v1.12.0

View File

@@ -1,4 +1,4 @@
VERSION=2.6 VERSION=$(shell git describe --exact-match --tags HEAD 2>/dev/null || echo bleeding)
GITHASH=$(shell git rev-parse --short=8 HEAD) GITHASH=$(shell git rev-parse --short=8 HEAD)
GO_TAGS = sqlite_foreign_keys sqlite_json sqlite_fts5 GO_TAGS = sqlite_foreign_keys sqlite_json sqlite_fts5

View File

@@ -291,14 +291,12 @@ func TestListItemsPaginated(t *testing.T) {
}) })
} }
func TestMarkItemsRead(t *testing.T) { func TestMarkAllItemsRead(t *testing.T) {
// NOTE: starred items must not be marked as read
var read model.ItemStatus = model.READ var read model.ItemStatus = model.READ
dbtest(t, func(t *testing.T, db storage.Storage) {
dbtest(t, func(t *testing.T, db1 storage.Storage) { testItemsSetup(db)
testItemsSetup(db1) db.MarkItemsRead(model.MarkFilter{})
db1.MarkItemsRead(model.MarkFilter{}) have := getItemGuids(db.ListItems(model.ItemFilter{Status: &read}, 10, false, false))
have := getItemGuids(db1.ListItems(model.ItemFilter{Status: &read}, 10, false, false))
want := []string{ want := []string{
"item111", "item112", "item121", "item122", "item111", "item112", "item121", "item122",
"item211", "item011", "item012", "item211", "item011", "item012",
@@ -309,11 +307,14 @@ func TestMarkItemsRead(t *testing.T) {
t.Fail() t.Fail()
} }
}) })
}
dbtest(t, func(t *testing.T, db2 storage.Storage) { func TestMarkItemsReadByFolder(t *testing.T) {
scope2 := testItemsSetup(db2) var read model.ItemStatus = model.READ
db2.MarkItemsRead(model.MarkFilter{FolderID: &scope2.folder1.Id}) dbtest(t, func(t *testing.T, db storage.Storage) {
have := getItemGuids(db2.ListItems(model.ItemFilter{Status: &read}, 10, false, false)) scope := testItemsSetup(db)
db.MarkItemsRead(model.MarkFilter{FolderID: &scope.folder1.Id})
have := getItemGuids(db.ListItems(model.ItemFilter{Status: &read}, 10, false, false))
want := []string{ want := []string{
"item111", "item112", "item121", "item122", "item111", "item112", "item121", "item122",
"item211", "item012", "item211", "item012",
@@ -324,11 +325,14 @@ func TestMarkItemsRead(t *testing.T) {
t.Fail() t.Fail()
} }
}) })
}
dbtest(t, func(t *testing.T, db3 storage.Storage) { func TestMarkItemsReadByFeed(t *testing.T) {
scope3 := testItemsSetup(db3) var read model.ItemStatus = model.READ
db3.MarkItemsRead(model.MarkFilter{FeedID: &scope3.feed11.Id}) dbtest(t, func(t *testing.T, db storage.Storage) {
have := getItemGuids(db3.ListItems(model.ItemFilter{Status: &read}, 10, false, false)) scope := testItemsSetup(db)
db.MarkItemsRead(model.MarkFilter{FeedID: &scope.feed11.Id})
have := getItemGuids(db.ListItems(model.ItemFilter{Status: &read}, 10, false, false))
want := []string{ want := []string{
"item111", "item112", "item122", "item111", "item112", "item122",
"item211", "item012", "item211", "item012",

View File

@@ -1,30 +1,32 @@
package tests package tests
import ( import (
"crypto/rand" "context"
"crypto/sha256"
"database/sql" "database/sql"
"encoding/hex"
"fmt" "fmt"
"net/url"
"os" "os"
"os/exec"
"strings"
"testing" "testing"
"time"
_ "github.com/lib/pq" _ "github.com/lib/pq"
"github.com/nkanaev/yarr/src/storage" "github.com/nkanaev/yarr/src/storage"
) )
func dbtest(t *testing.T, testcase func(t *testing.T, db storage.Storage)) { func dbtest(t *testing.T, testcase func(t *testing.T, db storage.Storage)) {
t.Parallel()
testurls := map[string]string{ testurls := map[string]string{
"sqlite": ":memory:", "sqlite": ":memory:",
} }
if pgUrl := os.Getenv("YARR_POSTGRES_TEST_URL"); pgUrl != "" { if pgImage := os.Getenv("YARR_POSTGRES_TEST_IMAGE"); pgImage != "" {
dburl, cleanup, err := createPostgresDB(pgUrl) dburl, cleanup := startPostgresContainer(t, pgImage)
if err != nil {
t.Fatalf("failed to create postgres test database: %v", err)
}
t.Cleanup(cleanup) t.Cleanup(cleanup)
testurls["postgres"] = dburl testurls["postgres"] = dburl
} else if !testing.Short() {
t.Fatalf("YARR_POSTGRES_TEST_IMAGE not set; use -short to skip docker tests")
} }
for testname, url := range testurls { for testname, url := range testurls {
@@ -38,49 +40,72 @@ func dbtest(t *testing.T, testcase func(t *testing.T, db storage.Storage)) {
} }
} }
func createPostgresDB(pgUrl string) (string, func(), error) { func startPostgresContainer(t *testing.T, image string) (string, func()) {
u, err := url.Parse(pgUrl) // database credentials
dbUser := "testuser"
dbPass := "password"
dbName := "yarrtest"
// generate unique container name
testHash := sha256.Sum256([]byte(t.Name()))
containerName := fmt.Sprintf("yarr-test-pg-%x-%d", testHash[:8], time.Now().UnixNano())
cmd := exec.Command(
"docker", "run", "-d", "--rm",
"--name", containerName,
"-p", "0:5432",
"-e", "POSTGRES_USER="+dbUser,
"-e", "POSTGRES_PASSWORD="+dbPass,
"-e", "POSTGRES_DB="+dbName,
image,
)
out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return "", nil, err t.Fatalf("failed to start postgres container: %v\n%s", err, string(out))
} }
u.Path = "/postgres" // retrieve the host port assigned by docker
adminConnStr := u.String() portCmd := exec.Command("docker", "port", containerName, "5432/tcp")
portOut, err := portCmd.Output()
adminDB, err := sql.Open("postgres", adminConnStr)
if err != nil { if err != nil {
return "", nil, fmt.Errorf("admin connect: %w", err) t.Fatalf("failed to get container port: %v", err)
} }
parts := strings.Split(strings.TrimSpace(string(portOut)), ":")
dbPort := parts[len(parts)-1]
b := make([]byte, 4) // build connection string
if _, err := rand.Read(b); err != nil { pgUrl := fmt.Sprintf(
adminDB.Close() "postgres://%s:%s@localhost:%s/%s?sslmode=disable",
return "", nil, fmt.Errorf("generate suffix: %w", err) dbUser,
} dbPass,
dbPort,
dbName,
)
testDBName := "yarr_test_" + hex.EncodeToString(b) // wait up to 15 seconds for the container to accept connections
deadline := time.Now().Add(15 * time.Second)
if _, err := adminDB.Exec(fmt.Sprintf(`CREATE DATABASE "%s"`, testDBName)); err != nil { for time.Now().Before(deadline) {
adminDB.Close() db, err := sql.Open("postgres", pgUrl)
return "", nil, fmt.Errorf("create database: %w", err)
}
adminDB.Close()
u.Path = "/" + testDBName
testURL := u.String()
cleanup := func() {
dropDB, err := sql.Open("postgres", adminConnStr)
if err != nil { if err != nil {
return continue
} }
defer dropDB.Close() ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
dropDB.Exec(fmt.Sprintf( err = db.PingContext(ctx)
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '%s' AND pid <> pg_backend_pid()`, cancel()
testDBName, db.Close()
)) if err == nil {
dropDB.Exec(fmt.Sprintf(`DROP DATABASE IF EXISTS "%s"`, testDBName)) goto ready
} }
time.Sleep(200 * time.Millisecond)
}
t.Fatalf("timed out waiting for postgres container to be ready")
return testURL, cleanup, nil ready:
// return connection url and a cleanup function that stops the container
return pgUrl, func() {
stop := exec.Command("docker", "stop", containerName)
if err := stop.Run(); err != nil {
t.Logf("failed to stop container %s: %v", containerName, err)
}
}
} }