Watch
1
0
Fork
You've already forked RedFlag
0

upstream: track prereleases per row; rename gitea adapter to forgejo

Most forges hand back a "latest stable" that ignores prereleases. Fine
until something ships only prereleases for a stretch — us, through
alpha, where every tag under v0.3.0 goes out as a prerelease. Against
that, /releases/latest either freezes or returns nothing, and the
dashboard reads like nothing's moving.

Add a per-row track_prereleases flag. When it's on, the forgejo adapter
walks the full release list and considers prereleases when picking the
highest version; off (the default) keeps stable-only behavior, so the
other adapters don't change. Seed our own self-row on so we stop
looking frozen.

Renamed gitea_releases to forgejo_releases while in there — the wire
format is Forgejo's, Codeberg runs it, and the old name was a misnomer.
Legacy source="gitea" rows still resolve through an alias.
This commit is contained in:
Fimeg 2026-06-15 12:04:48 -04:00
commit 9e4d59695f
18 changed files with 814 additions and 142 deletions

View file

@ -22,6 +22,9 @@ Format: version, date, then grouped by category (Added, Changed, Removed, Fixed,
### Added
- Desktop tray updates route through the privileged helper like every other install path.
- RedFlag self-tracking: startup seeds a default upstream row for
`codeberg.org/Fimeg/RedFlag`, tracks prereleases during alpha, and surfaces a
dashboard update banner with the operator-run rebuild command.
### Changed
- Web: interactive/brand surface recolored to steel blue, split cleanly from `danger` so

View file

@ -116,7 +116,7 @@ The full trust model lives in [SECURITY.md](SECURITY.md), including how to repor
- **Approval workflow** — updates queue for human review before anything runs
- **Maintenance windows** — day/time gates on when installs can proceed
- **Upstream tracking** — polls GitHub, Gitea, GitLab, Bitbucket for new releases; flags EOL drift
- **Upstream tracking** — polls GitHub, Forgejo/Gitea/Codeberg, GitLab, Bitbucket for new releases; flags EOL drift
- **Drift detection** — knows what should be installed vs. what is, bridges the gap into update packages
- **Agent self-update** — SHA-256 → signature → atomic binary swap → service restart, reconciled server-side
- **Dependency dry-run** — checks before installing, not after
@ -144,7 +144,7 @@ The full trust model lives in [SECURITY.md](SECURITY.md), including how to repor
- Capability-token minting for dnf/apt with Ed25519-signed token verification
- Ed25519 key rotation and replay protection
- Maintenance windows
- Upstream version tracking (GitHub, Gitea, GitLab, Bitbucket, Repology, endoflife.date)
- Upstream version tracking (GitHub, Forgejo/Gitea/Codeberg, GitLab, Bitbucket, Repology, endoflife.date)
- Metadata pipeline: CVE details, upstream intelligence, package provenance
- Auto-discovery bridge (Repology, container registry, exact match)
- Agent self-update via privileged helper (zero agent sudo)

View file

@ -23,9 +23,9 @@ import (
"github.com/Fimeg/RedFlag/server/internal/circuitbreaker"
"github.com/Fimeg/RedFlag/server/internal/command"
"github.com/Fimeg/RedFlag/server/internal/config"
"github.com/Fimeg/RedFlag/server/internal/integrations/wazuh"
"github.com/Fimeg/RedFlag/server/internal/database"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/integrations/wazuh"
"github.com/Fimeg/RedFlag/server/internal/logging"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/Fimeg/RedFlag/server/internal/observability"
@ -447,10 +447,32 @@ func main() {
upstreamRegistry.Register(upstream.NewRepology())
upstreamRegistry.Register(upstream.NewEndOfLife())
upstreamRegistry.Register(upstream.NewGithubReleases())
upstreamRegistry.Register(upstream.NewForgejoReleases())
upstreamRegistry.Register(upstream.NewGiteaReleases())
upstreamRegistry.Register(upstream.NewGitlabReleases())
upstreamRegistry.Register(upstream.NewBitbucketTags())
upstreamRegistry.Register(upstream.NewGitTags())
selfEnabled := true
trackPrereleases := true
selfCurrentVersion := version.AgentVersion
if selfCurrentVersion != "" && !strings.HasPrefix(selfCurrentVersion, "v") {
selfCurrentVersion = "v" + selfCurrentVersion
}
if inserted, err := upstreamQueries.SeedSelf(models.TrackedSoftwareInput{
Name: "RedFlag",
Ecosystem: "system",
Source: "forgejo",
SourceRef: "codeberg.org/Fimeg/RedFlag",
CurrentVersion: &selfCurrentVersion,
Enabled: &selfEnabled,
TrackPrereleases: &trackPrereleases,
RepologySlug: nil,
ContainerImagePattern: nil,
}); err != nil {
log.Printf("[WARN] [server] [upstream] seed RedFlag self-tracking failed: %v", err)
} else if inserted {
log.Printf("[INFO] [server] [upstream] seeded RedFlag self-tracking source=forgejo ref=codeberg.org/Fimeg/RedFlag current=%s track_prereleases=true", selfCurrentVersion)
}
upstreamSyncer := upstream.NewSyncer(upstreamQueries, upstreamRegistry, time.Hour, 6*time.Hour, 50, repologyQueries)
upstreamSyncer.Start(context.Background())
@ -540,14 +562,14 @@ func main() {
// Health check
recorder.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "healthy",
"version": version.AgentVersion,
"status": "healthy",
"version": version.AgentVersion,
})
})
recorder.GET("/api/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "healthy",
"version": version.AgentVersion,
"status": "healthy",
"version": version.AgentVersion,
})
})
@ -689,7 +711,7 @@ func main() {
if securitySettingsService != nil {
sweepInterval := time.Duration(getOperationalSetting(securitySettingsService, "orchestrator_sweep_interval_seconds", 60)) * time.Second
eventLogger := services.NewSystemEventLogger(db.DB)
// Set up external notification dispatch (ntfy, SMTP) if configured.
// Best-effort; failures here do not block the orchestrator.
disp := notifier.NewDispatcher(5 * time.Minute)
@ -940,11 +962,12 @@ func main() {
admin.DELETE("/maintenance-windows/:id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), maintenanceWindowHandler.DeleteWindow)
admin.GET("/maintenance-windows/check", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), maintenanceWindowHandler.CheckWindow)
// Upstream version sync (Repology + endoflife.date adapters)
// Upstream version sync (Repology, endoflife.date, forge releases, tags)
admin.GET("/upstream", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.List)
admin.GET("/upstream/drift", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.ListDrifted)
admin.GET("/upstream/drift/events", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.RecentDrift)
admin.POST("/upstream", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.Create)
admin.PATCH("/upstream/:id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.UpdateSettings)
admin.DELETE("/upstream/:id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.Delete)
admin.POST("/upstream/:id/sync", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.SyncNow)
admin.GET("/upstream/:id/installations", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), agentTrackedSoftwareHandler.ListInstallations)

View file

@ -2,6 +2,8 @@ package handlers
import (
"context"
"database/sql"
"errors"
"net/http"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
@ -71,7 +73,7 @@ func (h *UpstreamHandler) Create(c *gin.Context) {
}
if _, ok := h.registry.Get(in.Source); !ok {
c.JSON(http.StatusBadRequest, gin.H{
"error": "unknown source",
"error": "unknown source",
"available_sources": h.registry.Names(),
})
return
@ -93,6 +95,42 @@ func (h *UpstreamHandler) Create(c *gin.Context) {
c.JSON(http.StatusCreated, row)
}
func (h *UpstreamHandler) UpdateSettings(c *gin.Context) {
id, err := uuid.FromString(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var in models.TrackedSoftwareSettingsInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
return
}
row, err := h.queries.UpdateSettings(id, in)
if err != nil {
// sql.ErrNoRows (wrapped) means the UPDATE matched no row — a real
// not-found. Anything else is a connection/constraint failure and must
// not be misreported as 404; surface it as a server error so the cause
// isn't hidden behind a misleading "not found."
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "tracked software not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Kick an immediate sync when the operator changed something that affects
// latest_version: re-enabling a row, or flipping track_prereleases on an
// enabled one. Matches Create's prompt-sync behavior; fire-and-forget and
// idempotent, so a redundant kick on a no-op enable is harmless.
if row.Enabled && (in.Enabled != nil || in.TrackPrereleases != nil) {
h.bg("upstream_sync_one", func() {
_ = h.syncer.SyncOne(context.Background(), id)
})
}
c.JSON(http.StatusOK, row)
}
func (h *UpstreamHandler) Delete(c *gin.Context) {
id, err := uuid.FromString(c.Param("id"))
if err != nil {

View file

@ -0,0 +1,4 @@
-- Reverse of 059: drop the prerelease-tracking opt-in column.
ALTER TABLE tracked_software
DROP COLUMN IF EXISTS track_prereleases;

View file

@ -0,0 +1,25 @@
-- Migration 059: per-entry prerelease tracking for upstream sync.
--
-- Most upstream sources expose a "latest stable" shortcut that excludes
-- prereleases (Forgejo/Gitea/Codeberg's /releases/latest, GitHub's likewise).
-- For software that ships only prereleases for a stretch — RedFlag itself
-- during the alpha run, where every tag < v0.3.0 publishes as a prerelease —
-- that shortcut would freeze latest_version at the last stable (or never fill
-- it at all). track_prereleases opts a row into the full release walk so the
-- prerelease-aware adapters (forgejo) consider prereleases when picking latest.
--
-- Defaults FALSE: existing rows keep stable-only behavior; only entries that
-- explicitly opt in (or the seeded self-entry) follow prereleases.
ALTER TABLE tracked_software
ADD COLUMN IF NOT EXISTS track_prereleases BOOLEAN NOT NULL DEFAULT FALSE;
-- Existing canonical RedFlag self rows predate this column, so the DEFAULT
-- would make them stable-only throughout alpha. Flip only the exact managed
-- self-entry during migration; runtime reconciliation preserves later operator
-- choices after this one-time upgrade.
UPDATE tracked_software
SET track_prereleases = TRUE,
updated_at = NOW()
WHERE source = 'forgejo'
AND source_ref = 'codeberg.org/Fimeg/RedFlag';

View file

@ -1,7 +1,9 @@
package queries
import (
"database/sql"
"fmt"
"strings"
"time"
"github.com/Fimeg/RedFlag/server/internal/models"
@ -78,18 +80,39 @@ func (q *UpstreamQueries) Create(in models.TrackedSoftwareInput) (*models.Tracke
if in.Enabled != nil {
enabled = *in.Enabled
}
trackPre := false
if in.TrackPrereleases != nil {
trackPre = *in.TrackPrereleases
}
var row models.TrackedSoftware
err := q.db.Get(&row, `
INSERT INTO tracked_software (name, ecosystem, source, source_ref, current_version, enabled)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO tracked_software
(name, ecosystem, source, source_ref, current_version, enabled, track_prereleases, repology_slug, container_image_pattern, binary_probe)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
in.Name, in.Ecosystem, in.Source, in.SourceRef, in.CurrentVersion, enabled)
in.Name, in.Ecosystem, in.Source, in.SourceRef, in.CurrentVersion, enabled, trackPre,
in.RepologySlug, in.ContainerImagePattern, in.BinaryProbe)
if err != nil {
return nil, fmt.Errorf("upstream: create: %w", err)
}
return &row, nil
}
func (q *UpstreamQueries) UpdateSettings(id uuid.UUID, in models.TrackedSoftwareSettingsInput) (*models.TrackedSoftware, error) {
var row models.TrackedSoftware
err := q.db.Get(&row, `
UPDATE tracked_software
SET enabled = COALESCE($2, enabled),
track_prereleases = COALESCE($3, track_prereleases),
updated_at = NOW()
WHERE id = $1
RETURNING *`, id, in.Enabled, in.TrackPrereleases)
if err != nil {
return nil, fmt.Errorf("upstream: update_settings: %w", err)
}
return &row, nil
}
func (q *UpstreamQueries) Delete(id uuid.UUID) error {
res, err := q.db.Exec(`DELETE FROM tracked_software WHERE id = $1`, id)
if err != nil {
@ -146,6 +169,140 @@ func (q *UpstreamQueries) InsertDriftEvent(softwareID uuid.UUID, severity string
return nil
}
// SeedSelf upserts a tracked_software row keyed on (source, source_ref). It's
// used at startup to keep RedFlag's own self-tracking entry honest across
// upgrades without clobbering operator intent: on conflict it refreshes only
// current_version (so the row reflects the running build) and updated_at. It
// deliberately does NOT touch enabled or track_prereleases on conflict — an
// operator who disabled the self-entry, or flipped its prerelease tracking,
// keeps their choice. The DEFAULTs apply only on first insert.
// Known pre-canonical self rows are folded into the canonical key first so
// upgrades do not leave duplicate RedFlag entries.
//
// Returns true when a row was inserted (no prior self-entry), false when an
// existing row was refreshed, so the caller can log the first seed once.
func (q *UpstreamQueries) SeedSelf(in models.TrackedSoftwareInput) (inserted bool, err error) {
enabled := true
if in.Enabled != nil {
enabled = *in.Enabled
}
trackPre := false
if in.TrackPrereleases != nil {
trackPre = *in.TrackPrereleases
}
tx, err := q.db.Beginx()
if err != nil {
return false, fmt.Errorf("upstream: seed_self begin: %w", err)
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
if isCanonicalRedFlagSelf(in) {
if err = normalizeRedFlagSelfRows(tx, in, trackPre); err != nil {
return false, err
}
}
// xmax = 0 on the freshly-inserted tuple, non-zero on the row touched by DO
// UPDATE — the standard way to tell INSERT from UPDATE in an upsert RETURNING.
row := tx.QueryRow(`
INSERT INTO tracked_software (name, ecosystem, source, source_ref, current_version, enabled, track_prereleases)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (source, source_ref) DO UPDATE
SET current_version = EXCLUDED.current_version,
updated_at = NOW()
RETURNING (xmax = 0) AS inserted`,
in.Name, in.Ecosystem, in.Source, in.SourceRef, in.CurrentVersion, enabled, trackPre)
if err = row.Scan(&inserted); err != nil {
return false, fmt.Errorf("upstream: seed_self: %w", err)
}
if err = tx.Commit(); err != nil {
return false, fmt.Errorf("upstream: seed_self commit: %w", err)
}
return inserted, nil
}
func isCanonicalRedFlagSelf(in models.TrackedSoftwareInput) bool {
return strings.EqualFold(strings.TrimSpace(in.Name), "RedFlag") &&
in.Source == "forgejo" &&
strings.Trim(strings.TrimSpace(in.SourceRef), "/") == "codeberg.org/Fimeg/RedFlag"
}
func normalizeRedFlagSelfRows(tx *sqlx.Tx, in models.TrackedSoftwareInput, trackPre bool) error {
var canonicalID uuid.UUID
err := tx.QueryRow(`
SELECT id
FROM tracked_software
WHERE source = 'forgejo'
AND source_ref = 'codeberg.org/Fimeg/RedFlag'
LIMIT 1`).Scan(&canonicalID)
switch {
case err == nil:
if _, err := tx.Exec(`
DELETE FROM tracked_software
WHERE `+redFlagSelfLegacyWhere()+`
AND id <> $1`, canonicalID); err != nil {
return fmt.Errorf("upstream: seed_self delete legacy duplicates: %w", err)
}
return nil
case err != sql.ErrNoRows:
return fmt.Errorf("upstream: seed_self find canonical: %w", err)
}
var legacyID uuid.UUID
err = tx.QueryRow(`
SELECT id
FROM tracked_software
WHERE ` + redFlagSelfLegacyWhere() + `
ORDER BY created_at
LIMIT 1`).Scan(&legacyID)
switch {
case err == sql.ErrNoRows:
return nil
case err != nil:
return fmt.Errorf("upstream: seed_self find legacy: %w", err)
}
if _, err := tx.Exec(`
UPDATE tracked_software
SET name = $2,
ecosystem = $3,
source = $4,
source_ref = $5,
current_version = $6,
track_prereleases = $7,
updated_at = NOW()
WHERE id = $1`,
legacyID, in.Name, in.Ecosystem, in.Source, in.SourceRef, in.CurrentVersion, trackPre); err != nil {
return fmt.Errorf("upstream: seed_self canonicalize legacy: %w", err)
}
if _, err := tx.Exec(`
DELETE FROM tracked_software
WHERE `+redFlagSelfLegacyWhere()+`
AND id <> $1`, legacyID); err != nil {
return fmt.Errorf("upstream: seed_self delete extra legacy rows: %w", err)
}
return nil
}
func redFlagSelfLegacyWhere() string {
return `
LOWER(name) = 'redflag'
AND (
(source = 'gitea' AND source_ref IN (
'Fimeg/RedFlag',
'codeberg.org/Fimeg/RedFlag',
'https://codeberg.org/Fimeg/RedFlag',
'https://codeberg.org/Fimeg/RedFlag/'
))
OR (source = 'forgejo' AND source_ref IN (
'https://codeberg.org/Fimeg/RedFlag',
'https://codeberg.org/Fimeg/RedFlag/'
))
)`
}
func (q *UpstreamQueries) RecentDriftEvents(limit int) ([]models.UpstreamDriftEvent, error) {
if limit <= 0 {
limit = 25

View file

@ -10,39 +10,48 @@ import (
// server to keep an eye on. The (source, source_ref) pair uniquely
// identifies what upstream registry to ask and what to ask it for.
type TrackedSoftware struct {
ID uuid.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Ecosystem string `db:"ecosystem" json:"ecosystem"`
Source string `db:"source" json:"source"`
SourceRef string `db:"source_ref" json:"source_ref"`
CurrentVersion *string `db:"current_version" json:"current_version,omitempty"`
LatestVersion *string `db:"latest_version" json:"latest_version,omitempty"`
LatestAt *time.Time `db:"latest_at" json:"latest_at,omitempty"`
EOLAt *time.Time `db:"eol_at" json:"eol_at,omitempty"`
LastCheckedAt *time.Time `db:"last_checked_at" json:"last_checked_at,omitempty"`
LastSyncedAt *time.Time `db:"last_synced_at" json:"last_synced_at,omitempty"`
LastError *string `db:"last_error" json:"last_error,omitempty"`
Enabled bool `db:"enabled" json:"enabled"`
RepologySlug *string `db:"repology_slug" json:"repology_slug,omitempty"`
ContainerImagePattern *string `db:"container_image_pattern" json:"container_image_pattern,omitempty"`
BinaryProbe *string `db:"binary_probe" json:"binary_probe,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ID uuid.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Ecosystem string `db:"ecosystem" json:"ecosystem"`
Source string `db:"source" json:"source"`
SourceRef string `db:"source_ref" json:"source_ref"`
CurrentVersion *string `db:"current_version" json:"current_version,omitempty"`
LatestVersion *string `db:"latest_version" json:"latest_version,omitempty"`
LatestAt *time.Time `db:"latest_at" json:"latest_at,omitempty"`
EOLAt *time.Time `db:"eol_at" json:"eol_at,omitempty"`
LastCheckedAt *time.Time `db:"last_checked_at" json:"last_checked_at,omitempty"`
LastSyncedAt *time.Time `db:"last_synced_at" json:"last_synced_at,omitempty"`
LastError *string `db:"last_error" json:"last_error,omitempty"`
Enabled bool `db:"enabled" json:"enabled"`
TrackPrereleases bool `db:"track_prereleases" json:"track_prereleases"`
RepologySlug *string `db:"repology_slug" json:"repology_slug,omitempty"`
ContainerImagePattern *string `db:"container_image_pattern" json:"container_image_pattern,omitempty"`
BinaryProbe *string `db:"binary_probe" json:"binary_probe,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// TrackedSoftwareInput is the create/update payload from the admin API.
type TrackedSoftwareInput struct {
Name string `json:"name" binding:"required"`
Ecosystem string `json:"ecosystem" binding:"required"`
Source string `json:"source" binding:"required"`
SourceRef string `json:"source_ref" binding:"required"`
Name string `json:"name" binding:"required"`
Ecosystem string `json:"ecosystem" binding:"required"`
Source string `json:"source" binding:"required"`
SourceRef string `json:"source_ref" binding:"required"`
CurrentVersion *string `json:"current_version,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
TrackPrereleases *bool `json:"track_prereleases,omitempty"`
RepologySlug *string `json:"repology_slug,omitempty"`
ContainerImagePattern *string `json:"container_image_pattern,omitempty"`
BinaryProbe *string `json:"binary_probe,omitempty"`
}
// TrackedSoftwareSettingsInput is the narrow admin patch payload for
// operator-owned toggles that should not require recreating the tracked row.
type TrackedSoftwareSettingsInput struct {
Enabled *bool `json:"enabled,omitempty"`
TrackPrereleases *bool `json:"track_prereleases,omitempty"`
}
// UpstreamDriftEvent records a point-in-time observation that latest_version
// moved (or that an EOL date passed). Append-only.
type UpstreamDriftEvent struct {

View file

@ -0,0 +1,240 @@
package upstream
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// ForgejoReleases adapter — queries a Forgejo/Gitea/Codeberg instance for the
// newest versioned release of a repository.
//
// It walks /api/v1/repos/{owner}/{repo}/releases pages and picks the highest
// v-tag by RedFlag's pragmatic version comparator rather than trusting /releases/latest, which on
// Forgejo/Gitea/Codeberg EXCLUDES prereleases. During an alpha run where every
// tag publishes as a prerelease (RedFlag itself, < v0.3.0), /releases/latest
// would freeze or return nothing — so we list and choose. Drafts, non-v* tags,
// and the rolling `nightly` tag are filtered out; nightly is republished every
// night and would otherwise win on recency. Selection mirrors profile-engine's
// latest_release walk.
//
// Prereleases: when includePrerelease is false (the plain Fetch path), releases
// flagged prerelease are skipped. When true (the syncer calls FetchRelease for
// rows with track_prereleases), they're considered alongside stable ones.
//
// source_ref shapes accepted:
// - "host/owner/repo" e.g. "codeberg.org/Fimeg/RedFlag"
// - "https://host/owner/repo" scheme honored; defaults to https
// - "owner/repo" 2-part legacy form (source="gitea" rows): the
// host falls back to REDFLAG_GITEA_HOST so existing entries keep working.
//
// Auth: REDFLAG_GITEA_TOKEN (Bearer) is optional — public repos need none,
// private/self-hosted instances supply a token. Codeberg accepts one too.
type ForgejoReleases struct {
name string
fallbackHost string
token string
}
// NewForgejoReleases builds the adapter under its canonical "forgejo" name.
func NewForgejoReleases() *ForgejoReleases {
return &ForgejoReleases{
name: "forgejo",
fallbackHost: strings.TrimRight(os.Getenv("REDFLAG_GITEA_HOST"), "/"),
token: os.Getenv("REDFLAG_GITEA_TOKEN"),
}
}
// NewGiteaReleases keeps the legacy constructor available while routing it to
// the Forgejo-compatible implementation. Forgejo is a Gitea fork, and the
// release API shape RedFlag uses here is shared.
func NewGiteaReleases() *ForgejoReleases {
return NewForgejoReleases().WithName("gitea")
}
// WithName returns the same adapter configuration under an alias name. The
// registry keys by Name(), so registering the forgejo adapter twice — once as
// "forgejo", once as "gitea" — keeps legacy source="gitea" rows resolving to
// this (now prerelease-aware, list-walking) implementation.
func (f *ForgejoReleases) WithName(name string) *ForgejoReleases {
clone := *f
clone.name = name
return &clone
}
func (f ForgejoReleases) Name() string { return f.name }
type forgejoReleaseEnvelope struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
PublishedAt string `json:"published_at"`
HTMLURL string `json:"html_url"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
}
// Fetch is the stable-only path (includePrerelease=false), satisfying
// ReleaseSource. The syncer uses FetchRelease directly for prerelease-aware rows.
func (f ForgejoReleases) Fetch(ctx context.Context, ref string) (*Release, error) {
return f.FetchRelease(ctx, ref, false)
}
func (f ForgejoReleases) FetchRelease(ctx context.Context, ref string, includePrerelease bool) (*Release, error) {
host, owner, repo, err := f.resolveRef(ref)
if err != nil {
return nil, fmt.Errorf("forgejo: %w", err)
}
envs, err := f.fetchReleasePages(ctx, host, owner, repo)
if err != nil {
return nil, err
}
// Pick the highest v-tag. Drafts and non-v* / nightly tags are dropped;
// prereleases are dropped unless includePrerelease.
var best *forgejoReleaseEnvelope
for i := range envs {
e := &envs[i]
if e.Draft {
continue
}
if e.Prerelease && !includePrerelease {
continue
}
if !isVersionTag(e.TagName) {
continue
}
if best == nil || CompareVersions(e.TagName, best.TagName) > 0 {
best = e
}
}
if best == nil {
return nil, fmt.Errorf("forgejo: no versioned release for %s/%s (prereleases %s)", owner, repo, includeWord(includePrerelease))
}
rel := &Release{Version: best.TagName, SourceURL: best.HTMLURL}
if best.PublishedAt != "" {
if t, err := time.Parse(time.RFC3339, best.PublishedAt); err == nil {
rel.PublishedAt = &t
}
}
return rel, nil
}
func (f ForgejoReleases) fetchReleasePages(ctx context.Context, host, owner, repo string) ([]forgejoReleaseEnvelope, error) {
const (
pageLimit = 30
maxPages = 10
)
var all []forgejoReleaseEnvelope
for page := 1; page <= maxPages; page++ {
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases?limit=%d&page=%d", host, owner, repo, pageLimit, page)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("forgejo: build request: %w", err)
}
req.Header.Set("User-Agent", "RedFlag/0.2 (+https://codeberg.org/Fimeg/RedFlag)")
req.Header.Set("Accept", "application/json")
if f.token != "" {
req.Header.Set("Authorization", "Bearer "+f.token)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("forgejo: fetch %s/%s: %w", owner, repo, err)
}
switch resp.StatusCode {
case http.StatusOK:
case http.StatusNotFound:
resp.Body.Close()
return nil, fmt.Errorf("forgejo: %s/%s has no releases (or repo private/missing)", owner, repo)
case http.StatusUnauthorized, http.StatusForbidden:
resp.Body.Close()
return nil, fmt.Errorf("forgejo: auth required for %s/%s (set REDFLAG_GITEA_TOKEN)", owner, repo)
default:
resp.Body.Close()
return nil, fmt.Errorf("forgejo: status %d for %s/%s", resp.StatusCode, owner, repo)
}
var envs []forgejoReleaseEnvelope
if err := json.NewDecoder(resp.Body).Decode(&envs); err != nil {
resp.Body.Close()
return nil, fmt.Errorf("forgejo: decode: %w", err)
}
resp.Body.Close()
all = append(all, envs...)
if len(envs) < pageLimit {
break
}
}
return all, nil
}
func includeWord(b bool) string {
if b {
return "included"
}
return "excluded"
}
// resolveRef splits a source_ref into (host, owner, repo). Accepts a 3-part
// "host/owner/repo" (optionally scheme-prefixed) or a 2-part "owner/repo" that
// falls back to REDFLAG_GITEA_HOST. The returned host carries a scheme (https
// when none was supplied) and no trailing slash.
func (f ForgejoReleases) resolveRef(ref string) (host, owner, repo string, err error) {
ref = strings.TrimSpace(ref)
if ref == "" {
return "", "", "", fmt.Errorf("empty source_ref (want host/owner/repo or owner/repo)")
}
scheme := "https"
if i := strings.Index(ref, "://"); i >= 0 {
scheme = ref[:i]
ref = ref[i+3:]
}
ref = strings.Trim(ref, "/")
parts := strings.Split(ref, "/")
switch len(parts) {
case 2:
// Legacy "owner/repo" — host from env.
if f.fallbackHost == "" {
return "", "", "", fmt.Errorf("source_ref %q is owner/repo but REDFLAG_GITEA_HOST is not set", ref)
}
host = f.fallbackHost
owner, repo = parts[0], parts[1]
case 3:
host = scheme + "://" + parts[0]
owner, repo = parts[1], parts[2]
default:
return "", "", "", fmt.Errorf("source_ref %q is not host/owner/repo or owner/repo", ref)
}
if owner == "" || repo == "" {
return "", "", "", fmt.Errorf("source_ref %q has empty owner or repo", ref)
}
// Validate the env-derived fallback host parses (it may lack a scheme).
if !strings.Contains(host, "://") {
host = "https://" + host
}
if _, perr := url.Parse(host); perr != nil {
return "", "", "", fmt.Errorf("source_ref %q: bad host: %w", ref, perr)
}
return strings.TrimRight(host, "/"), owner, repo, nil
}
// isVersionTag reports whether tag looks like a v-prefixed numeric version
// (v1, v0.2.8.4, ...). Mirrors profile-engine's `^v\d` filter, which also drops
// the rolling `nightly` tag.
func isVersionTag(tag string) bool {
if len(tag) < 2 || tag[0] != 'v' {
return false
}
return tag[1] >= '0' && tag[1] <= '9'
}

View file

@ -0,0 +1,140 @@
package upstream
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)
func TestForgejoReleasesStableOnlySkipsPrereleases(t *testing.T) {
withForgejoHTTPClient(t, map[string][]forgejoReleaseEnvelope{
"1": {
{TagName: "v0.2.8.4", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/v0.2.8.4", Prerelease: true},
{TagName: "v0.2.7", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/v0.2.7"},
},
})
rel, err := NewForgejoReleases().Fetch(context.Background(), "forgejo.test/Fimeg/RedFlag")
if err != nil {
t.Fatalf("Fetch returned error: %v", err)
}
if rel.Version != "v0.2.7" {
t.Fatalf("Fetch() version = %q, want stable v0.2.7", rel.Version)
}
}
func TestForgejoReleasesCanIncludePrereleases(t *testing.T) {
withForgejoHTTPClient(t, map[string][]forgejoReleaseEnvelope{
"1": {
{TagName: "nightly", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/nightly", Prerelease: true},
{TagName: "v9.0.0", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/v9.0.0", Draft: true},
{TagName: "v0.2.8.4", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/v0.2.8.4", Prerelease: true},
{TagName: "v0.2.8.10", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/v0.2.8.10", Prerelease: true},
},
})
_, err := NewForgejoReleases().Fetch(context.Background(), "forgejo.test/Fimeg/RedFlag")
if err == nil || !strings.Contains(err.Error(), "prereleases excluded") {
t.Fatalf("Fetch() error = %v, want prerelease-excluded error", err)
}
rel, err := NewForgejoReleases().FetchRelease(context.Background(), "forgejo.test/Fimeg/RedFlag", true)
if err != nil {
t.Fatalf("FetchRelease(includePrerelease=true) returned error: %v", err)
}
if rel.Version != "v0.2.8.10" {
t.Fatalf("FetchRelease(includePrerelease=true) version = %q, want v0.2.8.10", rel.Version)
}
}
func TestForgejoReleasesFinalStableBeatsSameBasePrerelease(t *testing.T) {
withForgejoHTTPClient(t, map[string][]forgejoReleaseEnvelope{
"1": {
{TagName: "v0.3.0-alpha.4", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/v0.3.0-alpha.4", Prerelease: true},
{TagName: "v0.3.0", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/v0.3.0"},
},
})
rel, err := NewForgejoReleases().FetchRelease(context.Background(), "forgejo.test/Fimeg/RedFlag", true)
if err != nil {
t.Fatalf("FetchRelease(includePrerelease=true) returned error: %v", err)
}
if rel.Version != "v0.3.0" {
t.Fatalf("FetchRelease(includePrerelease=true) version = %q, want final stable v0.3.0", rel.Version)
}
}
func TestForgejoReleasesReadsBeyondFirstPage(t *testing.T) {
firstPage := make([]forgejoReleaseEnvelope, 30)
for i := range firstPage {
firstPage[i] = forgejoReleaseEnvelope{TagName: "nightly", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/nightly"}
}
withForgejoHTTPClient(t, map[string][]forgejoReleaseEnvelope{
"1": firstPage,
"2": {
{TagName: "v1.2.3", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/v1.2.3"},
},
})
rel, err := NewForgejoReleases().Fetch(context.Background(), "forgejo.test/Fimeg/RedFlag")
if err != nil {
t.Fatalf("Fetch returned error: %v", err)
}
if rel.Version != "v1.2.3" {
t.Fatalf("Fetch() version = %q, want v1.2.3 from page 2", rel.Version)
}
}
func TestGiteaAliasUsesFallbackHost(t *testing.T) {
withForgejoHTTPClient(t, map[string][]forgejoReleaseEnvelope{
"1": {
{TagName: "v1.0.0", HTMLURL: "https://gitea.example/releases/tag/v1.0.0"},
},
})
t.Setenv("REDFLAG_GITEA_HOST", "https://gitea.example")
rel, err := NewGiteaReleases().Fetch(context.Background(), "Fimeg/RedFlag")
if err != nil {
t.Fatalf("Fetch returned error: %v", err)
}
if rel.Version != "v1.0.0" {
t.Fatalf("Fetch() version = %q, want v1.0.0", rel.Version)
}
}
func withForgejoHTTPClient(t *testing.T, pages map[string][]forgejoReleaseEnvelope) {
t.Helper()
oldClient := httpClient
httpClient = &http.Client{Transport: forgejoRoundTripper{t: t, pages: pages}}
t.Cleanup(func() { httpClient = oldClient })
}
type forgejoRoundTripper struct {
t *testing.T
pages map[string][]forgejoReleaseEnvelope
}
func (f forgejoRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
f.t.Helper()
if req.URL.Path != "/api/v1/repos/Fimeg/RedFlag/releases" {
f.t.Fatalf("path = %q, want /api/v1/repos/Fimeg/RedFlag/releases", req.URL.Path)
}
if req.URL.Query().Get("limit") != "30" {
f.t.Fatalf("limit = %q, want 30", req.URL.Query().Get("limit"))
}
page := req.URL.Query().Get("page")
releases := f.pages[page]
body, err := json.Marshal(releases)
if err != nil {
f.t.Fatalf("marshal releases: %v", err)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(string(body))),
Request: req,
}, nil
}

View file

@ -1,99 +0,0 @@
package upstream
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
)
// GiteaReleases adapter — queries a self-hosted Gitea instance for the
// latest release of a repository.
//
// source_ref shape: "owner/repo". The host is supplied at adapter
// construction time via REDFLAG_GITEA_HOST (e.g. "https://gitea.local"
// or "http://10.10.20.120:4455"). If the host env is empty, the adapter
// still registers but every Fetch returns an "unconfigured" error so the
// operator sees what's missing in tracked_software.last_error.
//
// Auth via REDFLAG_GITEA_TOKEN env var (personal access token). Gitea
// accepts "token <pat>" or "Bearer <pat>" — we use Bearer for parity
// with the other adapters.
type GiteaReleases struct {
host string
token string
}
func NewGiteaReleases() *GiteaReleases {
return &GiteaReleases{
host: strings.TrimRight(os.Getenv("REDFLAG_GITEA_HOST"), "/"),
token: os.Getenv("REDFLAG_GITEA_TOKEN"),
}
}
func (GiteaReleases) Name() string { return "gitea" }
type giteaReleaseEnvelope struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
PublishedAt string `json:"published_at"`
HTMLURL string `json:"html_url"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
}
func (g GiteaReleases) Fetch(ctx context.Context, ref string) (*Release, error) {
if g.host == "" {
return nil, fmt.Errorf("gitea: REDFLAG_GITEA_HOST not set")
}
owner, repo, err := splitOwnerRepo(ref)
if err != nil {
return nil, fmt.Errorf("gitea: %w", err)
}
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases/latest", g.host, owner, repo)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("gitea: build request: %w", err)
}
req.Header.Set("User-Agent", "RedFlag/0.2 (+https://github.com/Fimeg/RedFlag)")
req.Header.Set("Accept", "application/json")
if g.token != "" {
req.Header.Set("Authorization", "Bearer "+g.token)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("gitea: fetch %s/%s: %w", owner, repo, err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
case http.StatusNotFound:
return nil, fmt.Errorf("gitea: %s/%s has no releases (or repo private/missing)", owner, repo)
case http.StatusUnauthorized, http.StatusForbidden:
return nil, fmt.Errorf("gitea: auth required for %s/%s (set REDFLAG_GITEA_TOKEN)", owner, repo)
default:
return nil, fmt.Errorf("gitea: status %d for %s/%s", resp.StatusCode, owner, repo)
}
var env giteaReleaseEnvelope
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
return nil, fmt.Errorf("gitea: decode: %w", err)
}
if env.TagName == "" {
return nil, fmt.Errorf("gitea: empty tag_name for %s/%s", owner, repo)
}
rel := &Release{Version: env.TagName, SourceURL: env.HTMLURL}
if env.PublishedAt != "" {
if t, err := time.Parse(time.RFC3339, env.PublishedAt); err == nil {
rel.PublishedAt = &t
}
}
return rel, nil
}

View file

@ -35,6 +35,19 @@ type ReleaseSource interface {
Fetch(ctx context.Context, ref string) (*Release, error)
}
// PrereleaseAwareSource is an optional capability some adapters implement when
// their registry distinguishes prereleases from stable releases. The syncer
// type-asserts for it and, for rows that opted into track_prereleases, calls
// FetchRelease(ctx, ref, true) so prereleases are considered when picking the
// latest version. Adapters that don't implement it keep the plain Fetch path
// (stable-only); for an adapter that does, Fetch must equal
// FetchRelease(ctx, ref, false). The base ReleaseSource.Fetch signature stays
// fixed so the other adapters need no changes.
type PrereleaseAwareSource interface {
ReleaseSource
FetchRelease(ctx context.Context, ref string, includePrerelease bool) (*Release, error)
}
// Registry holds the live adapters. Adapters self-register at construction
// time so main.go composes the set explicitly.
type Registry struct {

View file

@ -194,7 +194,17 @@ func (s *Syncer) syncOne(ctx context.Context, row models.TrackedSoftware) error
fetchCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
release, err := source.Fetch(fetchCtx, row.SourceRef)
var release *Release
var err error
if row.TrackPrereleases {
if prereleaseSource, ok := source.(PrereleaseAwareSource); ok {
release, err = prereleaseSource.FetchRelease(fetchCtx, row.SourceRef, true)
} else {
release, err = source.Fetch(fetchCtx, row.SourceRef)
}
} else {
release, err = source.Fetch(fetchCtx, row.SourceRef)
}
if err != nil {
log.Printf("[WARN] [upstream] [syncer] fetch failed source=%s ref=%s err=%v", row.Source, row.SourceRef, err)
if dbErr := s.queries.ApplySyncError(row.ID, err.Error()); dbErr != nil {

View file

@ -13,6 +13,9 @@ const StackDriftPanel: React.FC = () => {
const drift: TrackedSoftware[] = drifted ?? [];
const eolCount = drift.filter((s) => s.eol_at && new Date(s.eol_at) < new Date()).length;
const redflagDrift = drift.find(
(s) => s.source === 'forgejo' && s.source_ref === 'codeberg.org/Fimeg/RedFlag',
);
const worst3 = drift.slice(0, 3);
return (
@ -53,6 +56,28 @@ const StackDriftPanel: React.FC = () => {
)}
</div>
{redflagDrift && (
<div className="mb-4 rounded border border-amber-200 bg-amber-50 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-amber-900">RedFlag update available</p>
<p className="text-xs text-amber-800 font-mono mt-0.5">
{redflagDrift.current_version ?? '?'} {redflagDrift.latest_version ?? '?'}
</p>
</div>
<Link
to="/settings/upstream"
className="text-xs text-amber-800 hover:text-amber-950 font-medium whitespace-nowrap"
>
View row
</Link>
</div>
<code className="mt-2 block overflow-x-auto rounded bg-white/80 px-2 py-1 text-xs text-amber-950">
git checkout {redflagDrift.latest_version ?? 'v...'} && docker compose build server && docker compose up -d server
</code>
</div>
)}
<ul className="space-y-2">
{worst3.map((s) => {
const eolPassed = s.eol_at && new Date(s.eol_at) < new Date();

View file

@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-hot-toast';
import { adminApi } from '@/lib/api';
import { POLL } from '@/lib/polling';
import { CreateTrackedSoftwareRequest } from '@/types';
import { CreateTrackedSoftwareRequest, UpdateTrackedSoftwareSettingsRequest } from '@/types';
export const upstreamKeys = {
all: ['upstream'] as const,
@ -66,6 +66,20 @@ export const useRemoveTrackedSoftware = () => {
});
};
export const useUpdateTrackedSoftwareSettings = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: string; input: UpdateTrackedSoftwareSettingsRequest }) =>
adminApi.upstream.updateSettings(id, input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: upstreamKeys.all });
},
onError: (err: any) => {
toast.error(err.response?.data?.error || 'Failed to update tracked software');
},
});
};
export const useInstallations = (softwareId: string, enabled: boolean = true) => {
return useQuery({
queryKey: upstreamKeys.installations(softwareId),

View file

@ -24,6 +24,7 @@ import {
RateLimitStatsResponse,
TrackedSoftware,
CreateTrackedSoftwareRequest,
UpdateTrackedSoftwareSettingsRequest,
UpstreamDriftEvent,
AgentTrackedSoftware,
AgentTrackedSoftwareView,
@ -903,6 +904,11 @@ export const adminApi = {
return response.data;
},
updateSettings: async (id: string, input: UpdateTrackedSoftwareSettingsRequest): Promise<TrackedSoftware> => {
const response = await api.patch(`/admin/upstream/${id}`, input);
return response.data;
},
remove: async (id: string): Promise<void> => {
await api.delete(`/admin/upstream/${id}`);
},

View file

@ -18,6 +18,7 @@ import {
useAddTrackedSoftware,
useRemoveTrackedSoftware,
useSyncTrackedSoftware,
useUpdateTrackedSoftwareSettings,
useInstallations,
} from '@/hooks/useUpstream';
import { CreateTrackedSoftwareRequest, UpstreamSource } from '@/types';
@ -50,6 +51,7 @@ const sourceLabel = (s: UpstreamSource): string => {
case 'endoflife': return 'endoflife.date';
case 'anitya': return 'Anitya';
case 'github': return 'GitHub Releases';
case 'forgejo': return 'Forgejo Releases';
case 'gitea': return 'Gitea Releases';
case 'gitlab': return 'GitLab Releases';
case 'bitbucket': return 'Bitbucket Tags';
@ -68,7 +70,8 @@ const sourceRefHint = (s: UpstreamSource): { placeholder: string; help: string }
case 'endoflife': return { placeholder: 'postgresql', help: 'endoflife.date product slug' };
case 'anitya': return { placeholder: 'nginx', help: 'Anitya project name' };
case 'github': return { placeholder: 'kubernetes/kubernetes', help: 'GitHub owner/repo. Set REDFLAG_GITHUB_TOKEN for higher rate limit.' };
case 'gitea': return { placeholder: 'Fimeg/RedFlag', help: 'Gitea owner/repo. REDFLAG_GITEA_HOST + REDFLAG_GITEA_TOKEN required.' };
case 'forgejo': return { placeholder: 'codeberg.org/Fimeg/RedFlag', help: 'Forgejo host/owner/repo, such as codeberg.org/Fimeg/RedFlag.' };
case 'gitea': return { placeholder: 'Fimeg/RedFlag', help: 'Gitea owner/repo, or host/owner/repo. REDFLAG_GITEA_HOST used for two-part refs.' };
case 'gitlab': return { placeholder: 'gitlab-org/gitlab', help: 'GitLab group/project path. REDFLAG_GITLAB_TOKEN for private projects.' };
case 'bitbucket': return { placeholder: 'atlassian/atlassian-sdk', help: 'Bitbucket workspace/repo. Highest tag picked via semver compare.' };
case 'git': return { placeholder: 'https://git.kernel.org/….git', help: 'Any Git clone URL. Anonymous ls-remote; private repos use the dedicated adapters.' };
@ -81,15 +84,26 @@ const sourceRefHint = (s: UpstreamSource): { placeholder: string; help: string }
// "Source" link in the actions column. Falls back to the row's
// stored source_url when an adapter doesn't have a predictable URL
// pattern (or when host is env-configurable).
const forgejoHref = (ref: string): string | null => {
const clean = ref.trim().replace(/\/+$/, '');
if (/^https?:\/\//.test(clean)) return `${clean}/releases`;
const parts = clean.split('/');
if (parts.length === 3 && parts.every(Boolean)) {
return `https://${parts[0]}/${parts[1]}/${parts[2]}/releases`;
}
return null;
};
const sourceHref = (s: { source: UpstreamSource; source_ref: string }): string | null => {
switch (s.source) {
case 'repology': return `https://repology.org/project/${s.source_ref}/versions`;
case 'endoflife': return `https://endoflife.date/${s.source_ref}`;
case 'github': return `https://github.com/${s.source_ref}/releases`;
case 'forgejo': return forgejoHref(s.source_ref);
case 'gitlab': return `https://gitlab.com/${s.source_ref}/-/releases`;
case 'bitbucket': return `https://bitbucket.org/${s.source_ref}/downloads/?tab=tags`;
case 'git': return s.source_ref; // already a URL
case 'gitea':
case 'gitea': return forgejoHref(s.source_ref);
case 'anitya':
case 'npm':
case 'pypi':
@ -97,6 +111,9 @@ const sourceHref = (s: { source: UpstreamSource; source_ref: string }): string |
}
};
const supportsPrereleaseToggle = (source: UpstreamSource): boolean =>
source === 'forgejo' || source === 'gitea';
// InstallationsRow lazy-fetches the agent list for a tracked_software entry
// when the operator expands the row. Kept inside this file because it's only
// the table's expanded-row affordance, not a reusable component.
@ -150,6 +167,7 @@ const UpstreamTracking: React.FC = () => {
const add = useAddTrackedSoftware();
const remove = useRemoveTrackedSoftware();
const sync = useSyncTrackedSoftware();
const updateSettings = useUpdateTrackedSoftwareSettings();
const confirm = useConfirm();
const [expanded, setExpanded] = useState<Set<string>>(new Set());
@ -169,6 +187,7 @@ const UpstreamTracking: React.FC = () => {
source: 'endoflife',
source_ref: '',
current_version: '',
track_prereleases: false,
});
const software = data?.software ?? [];
@ -178,7 +197,7 @@ const UpstreamTracking: React.FC = () => {
if (!form.name || !form.source_ref) return;
add.mutate({ ...form, current_version: form.current_version || undefined }, {
onSuccess: () => {
setForm({ name: '', ecosystem: 'system', source: 'endoflife', source_ref: '', current_version: '' });
setForm({ name: '', ecosystem: 'system', source: 'endoflife', source_ref: '', current_version: '', track_prereleases: false });
setShowAddForm(false);
},
});
@ -205,8 +224,8 @@ const UpstreamTracking: React.FC = () => {
<div>
<h1 className="text-3xl font-bold text-gray-900">Upstream Version Tracking</h1>
<p className="mt-2 text-gray-600">
Compare your deployed versions against canonical upstream releases. Pluggable sources:
Repology (cross-distro) and endoflife.date (release lines + EOL dates).
Compare your deployed versions against canonical upstream releases across package indexes,
lifecycle feeds, forge releases, and repository tags.
</p>
</div>
<button
@ -248,7 +267,14 @@ const UpstreamTracking: React.FC = () => {
<label className="block text-sm font-medium text-gray-700 mb-1">Source</label>
<select
value={form.source}
onChange={(e) => setForm({ ...form, source: e.target.value as UpstreamSource })}
onChange={(e) => {
const nextSource = e.target.value as UpstreamSource;
setForm({
...form,
source: nextSource,
track_prereleases: supportsPrereleaseToggle(nextSource) ? form.track_prereleases : false,
});
}}
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{availableSources.map((s) => (
@ -280,6 +306,17 @@ const UpstreamTracking: React.FC = () => {
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
/>
</div>
{supportsPrereleaseToggle(form.source) && (
<label className="md:col-span-2 inline-flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={!!form.track_prereleases}
onChange={(e) => setForm({ ...form, track_prereleases: e.target.checked })}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
Track prereleases
</label>
)}
</div>
<div className="mt-4 flex gap-2 justify-end">
<button
@ -385,6 +422,9 @@ const UpstreamTracking: React.FC = () => {
<td className="px-6 py-3 text-sm text-gray-600">
{sourceLabel(s.source)}
<div className="text-xs text-gray-400 font-mono">{s.source_ref}</div>
{s.track_prereleases && (
<div className="text-xs text-blue-600">prereleases included</div>
)}
</td>
<td className="px-6 py-3 font-mono text-sm">{s.current_version ?? <span className="text-gray-400"></span>}</td>
<td className="px-6 py-3 font-mono text-sm">{s.latest_version ?? <span className="text-gray-400"></span>}</td>
@ -408,6 +448,21 @@ const UpstreamTracking: React.FC = () => {
)}
</td>
<td className="px-6 py-3 text-right space-x-2 whitespace-nowrap">
{supportsPrereleaseToggle(s.source) && (
<label className="inline-flex items-center gap-1 px-2 py-1 text-xs text-gray-600">
<input
type="checkbox"
checked={s.track_prereleases}
disabled={updateSettings.isPending}
onChange={(e) => updateSettings.mutate({
id: s.id,
input: { track_prereleases: e.target.checked },
})}
className="h-3 w-3 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
Prereleases
</label>
)}
<button
onClick={() => sync.mutate(s.id)}
disabled={sync.isPending}

View file

@ -574,6 +574,7 @@ export type UpstreamSource =
| 'endoflife'
| 'anitya'
| 'github'
| 'forgejo'
| 'gitea'
| 'gitlab'
| 'bitbucket'
@ -595,6 +596,7 @@ export interface TrackedSoftware {
last_synced_at?: string | null;
last_error?: string | null;
enabled: boolean;
track_prereleases: boolean;
repology_slug?: string | null;
container_image_pattern?: string | null;
binary_probe?: string | null;
@ -609,8 +611,15 @@ export interface CreateTrackedSoftwareRequest {
source_ref: string;
current_version?: string;
enabled?: boolean;
track_prereleases?: boolean;
repology_slug?: string;
container_image_pattern?: string;
binary_probe?: string;
}
export interface UpdateTrackedSoftwareSettingsRequest {
enabled?: boolean;
track_prereleases?: boolean;
}
export interface UpstreamDriftEvent {