<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[ReleaseBell: option to suppress prerelease notifications]]></title><description><![CDATA[<p dir="auto">Read against <code>eb9d805</code>. Last of three findings, and the one I would most like as a user.</p>
<p dir="auto">The <code>prerelease</code> column has been there since <code>20230926130714-add-release-prerelease.js</code>. It is fetched (<code>github.js:130</code>), stored (<code>database.js:174</code>), and labelled in both the subject line and the email template. But nothing filters on it — there is no user setting and no condition in <code>releasesListAllPending</code>.</p>
<p dir="auto">A live example. The three newest tags on <code>dbgate/dbgate</code> today:</p>
<pre><code>v7.2.4-premium-beta.7
v7.2.4-premium-beta.5
v7.2.4-beta.6
</code></pre>
<p dir="auto">while <code>GET /repos/dbgate/dbgate/releases/latest</code> still returns v7.2.3. So starring that repository currently sends three emails announcing versions that are not released, two of them for a <em>premium</em> build that is not the open-source product. Any project running a beta channel behaves the same way, so this is the normal case rather than an edge one.</p>
<p dir="auto">A sketch, defaulting to on so nothing changes for existing users:</p>
<pre><code class="language-sql">ALTER TABLE users ADD COLUMN notifyPrerelease BOOLEAN DEFAULT 1;
</code></pre>
<p dir="auto">Then in <code>releasesListAllPending</code>, join through to users — the flag is per-user while the pending list is release-scoped:</p>
<pre><code class="language-sql">SELECT releases.* FROM releases
  JOIN projects ON projects.id = releases.projectId
  JOIN users    ON users.id    = projects.userId
 WHERE releases.notified = 0
   AND (users.notifyPrerelease = 1 OR releases.prerelease = 0)
</code></pre>
<p dir="auto">Plus one checkbox beside the existing email setting.</p>
<p dir="auto">A per-project override would be better long term, since people tend to want stable-only for tools they merely use and prereleases for the one thing they develop against. But the per-user flag covers most of the value and is a smaller change.</p>
<p dir="auto">Happy to send this as a patch if that is useful.</p>
]]></description><link>https://forum.cloudron.io/topic/15775/releasebell-option-to-suppress-prerelease-notifications</link><generator>RSS for Node</generator><lastBuildDate>Fri, 04 Sep 2026 09:49:30 GMT</lastBuildDate><atom:link href="https://forum.cloudron.io/topic/15775.rss" rel="self" type="application/rss+xml"/><pubDate>Sun, 02 Aug 2026 15:46:25 GMT</pubDate><ttl>60</ttl><item><title><![CDATA[Reply to ReleaseBell: option to suppress prerelease notifications on Wed, 05 Aug 2026 09:42:55 GMT]]></title><description><![CDATA[<p dir="auto"><a class="plugin-mentions-user plugin-mentions-a" href="/user/timconsidine" aria-label="Profile: timconsidine">@<bdi>timconsidine</bdi></a> thank you for the link, that is the piece I was missing. I had been guessing at <code>packages/releasebell</code> and <code>cloudron/releasebell</code>; the group is <code>apps</code>, which I never tried.</p>
<p dir="auto">It does not get me all the way there, though. Signed in as <code> LoudLemur</code> the path 404s, and GitLab answers 404 rather than 403 for a private project you are not a member of, so I read that as the repository being there and my account not being on it. The <code>apps</code> group is public but lists no public projects, and <code>packages</code> lists exactly one, so I assume that is simply how app repos are kept rather than anything to do with me.</p>
<p dir="auto"><a class="plugin-mentions-user plugin-mentions-a" href="/user/girish" aria-label="Profile: girish">@<bdi>girish</bdi></a> that narrows what I asked for in my last post. I no longer need the path, only access to <code>apps/releasebell</code> for <code>LoudLemur</code>, or a fork pushed somewhere I can reach. Everything else on my side is unchanged: once I can read the real tree I will rebase onto current <code>master</code>, re-run my checks against that base instead of the archived 1.12.0 mirror, and open the MR.</p>
<p dir="auto">Incidentally the footer of the notification email points at <code>git.cloudron.io/cloudron/releasebell</code>, which is one of the paths that comes back not-found. Small thing, but the in-app link looks stale relative to <code>apps/</code>. Happy to fix it in the same MR.</p>
<p dir="auto">On the email rule: you are right that it works, and more cleanly than I would have guessed before looking. The subject is built as <code>&lt;project&gt; &lt;version&gt; (prerelease) released</code>, and that <code>(prerelease)</code> comes straight from the release API flag rather than from pattern-matching the tag, so a filter on that literal string catches exactly the prerelease mails and nothing else. No false hits on a project that happens to ship a version called <code>beta</code>.</p>
<p dir="auto">So it is a good workaround and worth having posted for anyone who needs one today. Three things it cannot do, which is why I still think the setting earns its place.</p>
<p dir="auto">It is all-or-nothing. The reason I ended up adding a per-project override on top of the per-user flag is that the useful setting is not "no prereleases" but "prereleases for the four projects I actually test against, and not for the other forty". Expressing that as mail rules means one rule per project, edited by hand every time you follow something new.</p>
<p dir="auto">It deletes rather than defers. Because a suppressed release stays <code>notified=FALSE</code> in the database, turning the setting back on delivers the prereleases you missed while it was off. That is deliberate, and it is what makes the setting safe to flip. A filtered email is gone.</p>
<p dir="auto">And it depends on a presentation detail. The subject string is template text, not API surface. If someone rewords it, the rule quietly stops matching and you start receiving prereleases again, or a reworded rule quietly starts eating real releases. Filters that fail in the delete direction fail silently.</p>
<p dir="auto">None of which is an argument against your rule in the meantime.</p>
]]></description><link>https://forum.cloudron.io/post/127751</link><guid isPermaLink="true">https://forum.cloudron.io/post/127751</guid><dc:creator><![CDATA[LoudLemur]]></dc:creator><pubDate>Wed, 05 Aug 2026 09:42:55 GMT</pubDate></item><item><title><![CDATA[Reply to ReleaseBell: option to suppress prerelease notifications on Wed, 05 Aug 2026 08:29:45 GMT]]></title><description><![CDATA[<p dir="auto">The code is here - <a href="https://git.cloudron.io/apps/releasebell" target="_blank" rel="noopener noreferrer nofollow ugc">https://git.cloudron.io/apps/releasebell</a></p>
]]></description><link>https://forum.cloudron.io/post/127742</link><guid isPermaLink="true">https://forum.cloudron.io/post/127742</guid><dc:creator><![CDATA[girish]]></dc:creator><pubDate>Wed, 05 Aug 2026 08:29:45 GMT</pubDate></item><item><title><![CDATA[Reply to ReleaseBell: option to suppress prerelease notifications on Tue, 04 Aug 2026 10:52:50 GMT]]></title><description><![CDATA[<p dir="auto"><a class="plugin-mentions-user plugin-mentions-a" href="/user/loudlemur" aria-label="Profile: LoudLemur">@<bdi>LoudLemur</bdi></a> can you not just setup an email rule which filters/deletes inbound notifications which contain certain words?</p>
]]></description><link>https://forum.cloudron.io/post/127698</link><guid isPermaLink="true">https://forum.cloudron.io/post/127698</guid><dc:creator><![CDATA[timconsidine]]></dc:creator><pubDate>Tue, 04 Aug 2026 10:52:50 GMT</pubDate></item><item><title><![CDATA[Reply to ReleaseBell: option to suppress prerelease notifications on Tue, 04 Aug 2026 10:09:27 GMT]]></title><description><![CDATA[<p dir="auto"><a class="plugin-mentions-user plugin-mentions-a" href="/user/girish" aria-label="Profile: girish">@<bdi>girish</bdi></a> Yes, happy to. Two things in the way, and one of them is my own mistake, so let me get that out first.</p>
<p dir="auto">The diffs above are against <code>eb9d805</code>, which is ReleaseBell <code>1.12.0</code> dated 2025-03-31. I took that from <code>github.com/cloudron-io/releasebell</code>, which turns out to be archived and to have had no push since that same date. The store is serving <code>1.13.1</code>. So I patched a mirror that stopped sixteen months ago and did not think to check it against the live version, which is entirely on me. I would not want you applying those diffs as posted.</p>
<p dir="auto">The second thing is access. I cannot find the repository to fork. <code>git.cloudron.io/packages</code> is public but exposes only <code>n8n-app</code>; searching for releasebell as <code>mostcloudron</code> returns nothing, and <code>packages/releasebell</code>, <code>packages/releasebell-app</code> and <code>cloudron/releasebell</code> all come back not-found rather than forbidden, so I cannot tell whether I am guessing the name wrong or simply cannot see it.</p>
<p dir="auto">Could you point me at the right path, and grant whatever access a fork needs? Once I can see it I will rebase onto current <code>master</code>, re-run the checks against that base rather than the stale one, and open the MR.</p>
<p dir="auto">For what it is worth the change itself should be unaffected. It adds a <code>notifyPrerelease</code> column on <code>users</code> defaulting to on, an optional nullable one on <code>projects</code> meaning "inherit the user setting", and a <code>COALESCE</code> in <code>releasesListAllPending</code>. Unless that query or the settings dialog moved between 1.12.0 and 1.13.1, it is the same patch with different line numbers. I will confirm rather than assume once I can read the real tree.</p>
]]></description><link>https://forum.cloudron.io/post/127695</link><guid isPermaLink="true">https://forum.cloudron.io/post/127695</guid><dc:creator><![CDATA[LoudLemur]]></dc:creator><pubDate>Tue, 04 Aug 2026 10:09:27 GMT</pubDate></item><item><title><![CDATA[Reply to ReleaseBell: option to suppress prerelease notifications on Tue, 04 Aug 2026 09:13:32 GMT]]></title><description><![CDATA[<p dir="auto">Do you think you can submit a MR instead?</p>
]]></description><link>https://forum.cloudron.io/post/127690</link><guid isPermaLink="true">https://forum.cloudron.io/post/127690</guid><dc:creator><![CDATA[girish]]></dc:creator><pubDate>Tue, 04 Aug 2026 09:13:32 GMT</pubDate></item><item><title><![CDATA[Reply to ReleaseBell: option to suppress prerelease notifications on Tue, 04 Aug 2026 07:55:58 GMT]]></title><description><![CDATA[<p dir="auto">Instead of (NOT as well as) the second patch, you could alternatively do this: the whole thing as a single patch against <code>1.12.0</code> (<code>eb9d805</code>), in case that is easier to take than the two separate ones. This replaces both of the diffs above rather than adding to them, so apply this or those, not both.</p>
<p dir="auto">It does two levels of setting. A <code>notifyPrerelease</code> flag on <code>users</code> defaulting to on, which is the suppression toggle asked for at the top of the thread, and a nullable <code>notifyPrerelease</code> on <code>projects</code> where <code>NULL</code> means "inherit the user setting". The pending release query resolves them with <code>COALESCE(projects.notifyPrerelease, users.notifyPrerelease)</code>, project first. Existing users default to on and existing projects all migrate to <code>NULL</code>, so an upgrade changes nobody's mail until they touch a setting.</p>
<p dir="auto">The two levels together cover both directions people want. Untick the box in Settings and prereleases stop everywhere, which is the original request. Or leave it ticked and set a noisy project to Never. Or untick it globally and set Always on the one project you are actually testing, which is the case I suspect is most common and which the user flag alone cannot express.</p>
<p dir="auto">In the UI that is a checkbox in the Settings dialog and a three-way dropdown per row in the project table, reading Default, Always, Never. <code>Checkbox</code> and <code>Dropdown</code> were both already registered, so this adds no new dependency.</p>
<p dir="auto">I checked the migration and the query against MariaDB 11 rather than reasoning about them. Users and projects created before the columns existed come out on and <code>NULL</code> respectively, so upgrading is a no-op, and both <code>down</code> migrations drop cleanly with no row loss. Across all six combinations of user setting against project override the resolution comes out as intended.</p>
<pre><code>project    user_pref  project_pref  outcome
always     on         Always        notified
inherit    on         Default       notified
never      on         Never         suppressed
always     off        Always        notified
inherit    off        Default       suppressed
never      off        Never         suppressed
</code></pre>
<p dir="auto">Stable releases are unaffected throughout, including for a user who has prereleases off everywhere, and an already-notified release stays excluded as before.</p>
<p dir="auto">Two things worth knowing before you take it. A suppressed prerelease keeps <code>notified=FALSE</code> rather than being marked notified, so if someone turns the setting back on they will receive the prereleases published while it was off. That is what makes the setting reversible without losing anything, but say if you would rather they were silently dropped, which is a one-line change. And I corrected <code>migrations/current_schema.sql</code>, which declared the releases column as <code>prereleases</code> while the migration and all code use <code>prerelease</code>. That file is marked "Only for reference" so nothing was broken, but it is the file someone reads before writing exactly this patch.</p>
<pre><code class="language-diff">diff --git a/backend/database.js b/backend/database.js
index 57e6918..afc0aab 100644
--- a/backend/database.js
+++ b/backend/database.js
@@ -60,6 +60,8 @@ function init() {
 function projectPostprocess(p) {
     if (p.lastSuccessfulSyncAt === '0000-00-00 00:00:00') p.lastSuccessfulSyncAt = 0;
     p.enabled = !!p.enabled;
+    // null is meaningful here: it means "inherit the user setting", so only coerce an actual value
+    if (p.notifyPrerelease !== null &amp;&amp; p.notifyPrerelease !== undefined) p.notifyPrerelease = !!p.notifyPrerelease;
     return p;
 }
 
@@ -123,16 +125,23 @@ async function projectsRemove(projectId) {
     await db.query('DELETE FROM projects WHERE id=?', [ projectId ]);
 }
 
+function userPostprocess(u) {
+    u.notifyPrerelease = !!u.notifyPrerelease;
+    return u;
+}
+
 async function usersList() {
     const [result] = await db.query('SELECT * FROM users', []);
-    return result;
+    return result.map(userPostprocess);
 }
 
 async function usersAdd(user) {
     assert.strictEqual(typeof user, 'object');
 
-    await db.query('INSERT INTO users (id, email, githubToken) VALUES (?, ?, ?)',
-        [ user.id, user.email, user.githubToken ]);
+    if (typeof user.notifyPrerelease !== 'boolean') user.notifyPrerelease = true;
+
+    await db.query('INSERT INTO users (id, email, githubToken, notifyPrerelease) VALUES (?, ?, ?, ?)',
+        [ user.id, user.email, user.githubToken, user.notifyPrerelease ]);
 
     return user;
 }
@@ -143,16 +152,17 @@ async function usersGet(userId) {
     const [result] = await db.query('SELECT * FROM users WHERE id=?', [ userId ]);
     if (!result.length) throw new Error('no such user');
 
-    return result[0];
+    return userPostprocess(result[0]);
 }
 
-async function usersUpdate(userId, githubToken, email) {
+async function usersUpdate(userId, githubToken, email, notifyPrerelease) {
     assert.strictEqual(typeof userId, 'string');
     assert.strictEqual(typeof githubToken, 'string');
     assert.strictEqual(typeof email, 'string');
+    assert.strictEqual(typeof notifyPrerelease, 'boolean');
 
-    let args = [ githubToken, email, userId ];
-    let query = 'UPDATE users SET githubToken=?,email=? WHERE id=?';
+    let args = [ githubToken, email, notifyPrerelease, userId ];
+    let query = 'UPDATE users SET githubToken=?,email=?,notifyPrerelease=? WHERE id=?';
 
     await db.query(query, args);
 }
@@ -186,6 +196,12 @@ async function releasesUpdate(releaseId, data) {
 }
 
 async function releasesListAllPending() {
-    const [result] = await db.query('SELECT * FROM releases WHERE notified=FALSE', []);
+    // a prerelease is only pending if prerelease notifications are on for it. the project setting
+    // wins when set, and falls back to the user setting when NULL
+    const [result] = await db.query('SELECT releases.* FROM releases' +
+        ' JOIN projects ON projects.id=releases.projectId' +
+        ' JOIN users ON users.id=projects.userId' +
+        ' WHERE releases.notified=FALSE AND (releases.prerelease=FALSE' +
+        ' OR COALESCE(projects.notifyPrerelease, users.notifyPrerelease)=TRUE)', []);
     return result;
 }
diff --git a/backend/routes.js b/backend/routes.js
index 90c87ce..f21ee85 100644
--- a/backend/routes.js
+++ b/backend/routes.js
@@ -61,7 +61,7 @@ async function auth(req, res, next) {
     // update email if changed
     if (user.email !== req.oidc.user.email) {
         try {
-            await database.users.update(user.id, user.githubToken, req.oidc.user.email);
+            await database.users.update(user.id, user.githubToken, req.oidc.user.email, user.notifyPrerelease);
             user.email = req.oidc.user.email;
         } catch (e) {
             console.error('Failed to update email for user.', user, e);
@@ -84,6 +84,9 @@ async function profileUpdate(req, res, next) {
 
     const githubToken = req.body.githubToken || '';
 
+    // absent means "leave unchanged", so a client that does not know about this field cannot reset it
+    const notifyPrerelease = 'notifyPrerelease' in req.body ? !!req.body.notifyPrerelease : req.user.notifyPrerelease;
+
     try {
         await github.verifyToken(githubToken);
     } catch (error) {
@@ -91,11 +94,12 @@ async function profileUpdate(req, res, next) {
     }
 
     try {
-        await database.users.update(req.user.id, githubToken, req.user.email);
+        await database.users.update(req.user.id, githubToken, req.user.email, notifyPrerelease);
     } catch (error) {
         return next(new HttpError(500, error));
     }
     req.user.githubToken = githubToken;
+    req.user.notifyPrerelease = notifyPrerelease;
 
     next(new HttpSuccess(202, {}));
 
diff --git a/frontend/App.vue b/frontend/App.vue
index c259601..fb4cb67 100644
--- a/frontend/App.vue
+++ b/frontend/App.vue
@@ -40,6 +40,10 @@
               &lt;br/&gt;
               &lt;a href="https://github.com/settings/tokens/new?description=ReleaseBell" target="_blank" style="margin-top: 10px;"&gt;Generate a GitHub API token&lt;/a&gt;
             &lt;/div&gt;
+            &lt;div class="form-field"&gt;
+              &lt;Checkbox id="notifyPrereleaseInput" v-model="settingsDialog.notifyPrerelease" :binary="true"/&gt;
+              &lt;label for="notifyPrereleaseInput" style="margin-left: 10px;"&gt;Notify me about prereleases&lt;/label&gt;
+            &lt;/div&gt;
           &lt;/div&gt;
         &lt;/form&gt;
         &lt;template #footer&gt;
@@ -92,6 +96,11 @@
             {{ prettyDate(slotProps.data.createdAt) }}
           &lt;/template&gt;
         &lt;/Column&gt;
+        &lt;Column field="notifyPrerelease" header="Prereleases" sortable&gt;
+          &lt;template #body="slotProps"&gt;
+            &lt;Dropdown v-model="slotProps.data.notifyPrerelease" :options="prereleaseOptions" optionLabel="name" optionValue="value" @change="onPrereleaseStateChanged(slotProps.data)"/&gt;
+          &lt;/template&gt;
+        &lt;/Column&gt;
         &lt;Column field="enabled" header="Track" sortable&gt;
           &lt;template #body="slotProps"&gt;
             &lt;InputSwitch v-model="slotProps.data.enabled" @change="onTrackStateChanged(slotProps.data)" /&gt;
@@ -142,6 +151,16 @@ export default {
         type: 'gitlab',
         name: 'GitLab'
       }],
+      prereleaseOptions: [{
+        value: null,
+        name: 'Default'
+      }, {
+        value: true,
+        name: 'Always'
+      }, {
+        value: false,
+        name: 'Never'
+      }],
       addProjectDialog: {
         visible: false,
         busy: false,
@@ -151,6 +170,7 @@ export default {
         visible: false,
         busy: false,
         error: '',
+        notifyPrerelease: true,
         data: {}
       },
     };
@@ -180,6 +200,9 @@ export default {
     async onTrackStateChanged(project) {
       await superagent.post(`${API_ORIGIN}/api/v1/projects/${project.id}`).send({ enabled: project.enabled });
     },
+    async onPrereleaseStateChanged(project) {
+      await superagent.post(`${API_ORIGIN}/api/v1/projects/${project.id}`).send({ notifyPrerelease: project.notifyPrerelease });
+    },
     async onLogout() {
       await superagent.get(`${API_ORIGIN}/api/v1/logout?return_to=${location.origin}`);
       this.user = null;
@@ -198,6 +221,7 @@ export default {
     },
     onShowSettingsDialog() {
       this.settingsDialog.githubToken = this.user.githubToken;
+      this.settingsDialog.notifyPrerelease = this.user.notifyPrerelease !== false;
       this.settingsDialog.error = '';
       this.settingsDialog.visible = true;
     },
@@ -228,7 +252,7 @@ export default {
       this.settingsDialog.error = '';
 
       try {
-        await superagent.post(`${API_ORIGIN}/api/v1/profile`).send({ githubToken: this.settingsDialog.githubToken });
+        await superagent.post(`${API_ORIGIN}/api/v1/profile`).send({ githubToken: this.settingsDialog.githubToken, notifyPrerelease: this.settingsDialog.notifyPrerelease });
       } catch (error) {
         if (error.status === 402) {
           document.getElementById('githubTokenInput').focus();
@@ -241,6 +265,8 @@ export default {
         return;
       }
 
+      this.user.notifyPrerelease = this.settingsDialog.notifyPrerelease;
+
       this.settingsDialog.busy = false;
       this.settingsDialog.visible = false;
     }
diff --git a/migrations/20260804120000-add-user-notify-prerelease.js b/migrations/20260804120000-add-user-notify-prerelease.js
new file mode 100644
index 0000000..28f2ef2
--- /dev/null
+++ b/migrations/20260804120000-add-user-notify-prerelease.js
@@ -0,0 +1,9 @@
+'use strict';
+
+exports.up = function(db, callback) {
+  db.runSql('ALTER TABLE users ADD COLUMN notifyPrerelease BOOLEAN DEFAULT 1', callback);
+};
+
+exports.down = function(db, callback) {
+    db.runSql('ALTER TABLE users DROP COLUMN notifyPrerelease', callback);
+};
diff --git a/migrations/20260804130000-add-project-notify-prerelease.js b/migrations/20260804130000-add-project-notify-prerelease.js
new file mode 100644
index 0000000..28b572f
--- /dev/null
+++ b/migrations/20260804130000-add-project-notify-prerelease.js
@@ -0,0 +1,10 @@
+'use strict';
+
+// NULL means "inherit the user's notifyPrerelease setting"
+exports.up = function(db, callback) {
+  db.runSql('ALTER TABLE projects ADD COLUMN notifyPrerelease BOOLEAN DEFAULT NULL', callback);
+};
+
+exports.down = function(db, callback) {
+    db.runSql('ALTER TABLE projects DROP COLUMN notifyPrerelease', callback);
+};
diff --git a/migrations/current_schema.sql b/migrations/current_schema.sql
index 76bfcd6..7af7b3a 100644
--- a/migrations/current_schema.sql
+++ b/migrations/current_schema.sql
@@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS users(
     id VARCHAR(128) NOT NULL UNIQUE,
     email VARCHAR(512) NOT NULL,
     githubToken VARCHAR(512) NOT NULL DEFAULT "",
+    notifyPrerelease BOOLEAN DEFAULT true,
 
     PRIMARY KEY(id));
 
@@ -15,6 +16,7 @@ CREATE TABLE IF NOT EXISTS projects(
     enabled BOOLEAN DEFAULT true,
     lastSuccessfulSyncAt BIGINT DEFAULT 0,
     type VARCHAR(32) NOT NULL DEFAULT "github",
+    notifyPrerelease BOOLEAN DEFAULT NULL,      -- NULL means inherit the user's setting
 
     FOREIGN KEY(userId) REFERENCES users(id),
     PRIMARY KEY(id));
@@ -25,7 +27,7 @@ CREATE TABLE IF NOT EXISTS releases(
     version VARCHAR(512) NOT NULL,
     body TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
     notified BOOLEAN DEFAULT false,
-    prereleases BOOLEAN DEFAULT false,
+    prerelease BOOLEAN DEFAULT false,
     createdAt BIGINT NOT NULL,
 
     FOREIGN KEY(projectId) REFERENCES projects(id),
</code></pre>
]]></description><link>https://forum.cloudron.io/post/127682</link><guid isPermaLink="true">https://forum.cloudron.io/post/127682</guid><dc:creator><![CDATA[LoudLemur]]></dc:creator><pubDate>Tue, 04 Aug 2026 07:55:58 GMT</pubDate></item><item><title><![CDATA[Reply to ReleaseBell: option to suppress prerelease notifications on Tue, 04 Aug 2026 07:45:44 GMT]]></title><description><![CDATA[<p dir="auto">Follow-up to the patch above, for anyone who wants the opposite of suppression: betas for one specific project while staying on stable everywhere else. This applies on top of the previous patch rather than replacing it, so apply that one first.</p>
<p dir="auto">The shape is a nullable <code>notifyPrerelease</code> on <code>projects</code>, where <code>NULL</code> means "inherit whatever the user setting says". Existing projects all come out <code>NULL</code> on migration, so nothing changes for anyone until they deliberately set a project to something else. The pending query resolves the pair with <code>COALESCE(projects.notifyPrerelease, users.notifyPrerelease)</code>, project first. In the UI it is a three-way dropdown per row in the project table, reading Default, Always, Never.</p>
<p dir="auto">That gives the case the per-user flag on its own cannot express, and which I think is the one most people actually want. Turn prereleases off globally, then set Always on the one project you are testing, and you get its betas and nobody else's.</p>
<p dir="auto">I ran the same MariaDB check over all six combinations of user setting against project override, using a pending prerelease per project.</p>
<pre><code>project    user_pref  project_pref  outcome
always     on         Always        notified
inherit    on         Default       notified
never      on         Never         suppressed
always     off        Always        notified
inherit    off        Default       suppressed
never      off        Never         suppressed
</code></pre>
<p dir="auto">Stable releases are untouched throughout, including for a user who has prereleases off everywhere. Before the migration ran, all six existing projects came out <code>NULL</code>, so an upgrade is a no-op. <code>down</code> drops the column cleanly.</p>
<p dir="auto">One note on where the setting lives, in case you would rather it went elsewhere. I put the dropdown in the project table next to Track, because that is where the per-project state already is and it needs no new dialog. It does add a column to a table that is already fairly wide, so if you would prefer it tucked into a per-project menu instead, that is a UI-only change and the backend stands as is.</p>
<pre><code class="language-diff">diff --git a/backend/database.js b/backend/database.js
index 8960c5c..afc0aab 100644
--- a/backend/database.js
+++ b/backend/database.js
@@ -60,6 +60,8 @@ function init() {
 function projectPostprocess(p) {
     if (p.lastSuccessfulSyncAt === '0000-00-00 00:00:00') p.lastSuccessfulSyncAt = 0;
     p.enabled = !!p.enabled;
+    // null is meaningful here: it means "inherit the user setting", so only coerce an actual value
+    if (p.notifyPrerelease !== null &amp;&amp; p.notifyPrerelease !== undefined) p.notifyPrerelease = !!p.notifyPrerelease;
     return p;
 }
 
@@ -194,10 +196,12 @@ async function releasesUpdate(releaseId, data) {
 }
 
 async function releasesListAllPending() {
-    // a prerelease is only pending for users who have not opted out of prerelease notifications
+    // a prerelease is only pending if prerelease notifications are on for it. the project setting
+    // wins when set, and falls back to the user setting when NULL
     const [result] = await db.query('SELECT releases.* FROM releases' +
         ' JOIN projects ON projects.id=releases.projectId' +
         ' JOIN users ON users.id=projects.userId' +
-        ' WHERE releases.notified=FALSE AND (releases.prerelease=FALSE OR users.notifyPrerelease=TRUE)', []);
+        ' WHERE releases.notified=FALSE AND (releases.prerelease=FALSE' +
+        ' OR COALESCE(projects.notifyPrerelease, users.notifyPrerelease)=TRUE)', []);
     return result;
 }
diff --git a/frontend/App.vue b/frontend/App.vue
index f200eba..fb4cb67 100644
--- a/frontend/App.vue
+++ b/frontend/App.vue
@@ -96,6 +96,11 @@
             {{ prettyDate(slotProps.data.createdAt) }}
           &lt;/template&gt;
         &lt;/Column&gt;
+        &lt;Column field="notifyPrerelease" header="Prereleases" sortable&gt;
+          &lt;template #body="slotProps"&gt;
+            &lt;Dropdown v-model="slotProps.data.notifyPrerelease" :options="prereleaseOptions" optionLabel="name" optionValue="value" @change="onPrereleaseStateChanged(slotProps.data)"/&gt;
+          &lt;/template&gt;
+        &lt;/Column&gt;
         &lt;Column field="enabled" header="Track" sortable&gt;
           &lt;template #body="slotProps"&gt;
             &lt;InputSwitch v-model="slotProps.data.enabled" @change="onTrackStateChanged(slotProps.data)" /&gt;
@@ -146,6 +151,16 @@ export default {
         type: 'gitlab',
         name: 'GitLab'
       }],
+      prereleaseOptions: [{
+        value: null,
+        name: 'Default'
+      }, {
+        value: true,
+        name: 'Always'
+      }, {
+        value: false,
+        name: 'Never'
+      }],
       addProjectDialog: {
         visible: false,
         busy: false,
@@ -185,6 +200,9 @@ export default {
     async onTrackStateChanged(project) {
       await superagent.post(`${API_ORIGIN}/api/v1/projects/${project.id}`).send({ enabled: project.enabled });
     },
+    async onPrereleaseStateChanged(project) {
+      await superagent.post(`${API_ORIGIN}/api/v1/projects/${project.id}`).send({ notifyPrerelease: project.notifyPrerelease });
+    },
     async onLogout() {
       await superagent.get(`${API_ORIGIN}/api/v1/logout?return_to=${location.origin}`);
       this.user = null;
diff --git a/migrations/20260804130000-add-project-notify-prerelease.js b/migrations/20260804130000-add-project-notify-prerelease.js
new file mode 100644
index 0000000..28b572f
--- /dev/null
+++ b/migrations/20260804130000-add-project-notify-prerelease.js
@@ -0,0 +1,10 @@
+'use strict';
+
+// NULL means "inherit the user's notifyPrerelease setting"
+exports.up = function(db, callback) {
+  db.runSql('ALTER TABLE projects ADD COLUMN notifyPrerelease BOOLEAN DEFAULT NULL', callback);
+};
+
+exports.down = function(db, callback) {
+    db.runSql('ALTER TABLE projects DROP COLUMN notifyPrerelease', callback);
+};
diff --git a/migrations/current_schema.sql b/migrations/current_schema.sql
index 8e2ecb6..7af7b3a 100644
--- a/migrations/current_schema.sql
+++ b/migrations/current_schema.sql
@@ -16,6 +16,7 @@ CREATE TABLE IF NOT EXISTS projects(
     enabled BOOLEAN DEFAULT true,
     lastSuccessfulSyncAt BIGINT DEFAULT 0,
     type VARCHAR(32) NOT NULL DEFAULT "github",
+    notifyPrerelease BOOLEAN DEFAULT NULL,      -- NULL means inherit the user's setting
 
     FOREIGN KEY(userId) REFERENCES users(id),
     PRIMARY KEY(id));
</code></pre>
]]></description><link>https://forum.cloudron.io/post/127681</link><guid isPermaLink="true">https://forum.cloudron.io/post/127681</guid><dc:creator><![CDATA[LoudLemur]]></dc:creator><pubDate>Tue, 04 Aug 2026 07:45:44 GMT</pubDate></item><item><title><![CDATA[Reply to ReleaseBell: option to suppress prerelease notifications on Tue, 04 Aug 2026 07:26:48 GMT]]></title><description><![CDATA[<blockquote>
<p dir="auto"><a class="plugin-mentions-user plugin-mentions-a" href="/user/girish" aria-label="Profile: girish">@<bdi>girish</bdi></a> <a href="/post/127675">said</a>:</p>
<p dir="auto">I think this is a good idea. <a class="plugin-mentions-user plugin-mentions-a" href="/user/loudlemur" aria-label="Profile: loudlemur">@<bdi>loudlemur</bdi></a> feel free to post a patch and I can merge it.</p>
</blockquote>
<p dir="auto">Thanks. Patch below, against <code>1.12.0</code> (<code>eb9d805</code>).</p>
<p dir="auto">It does the per-user flag only, as described above. A new <code>notifyPrerelease</code> column on <code>users</code> defaults to <code>1</code>, so existing installs keep their current behaviour after migrating and nobody has to go and re-enable anything. <code>releasesListAllPending</code> then joins through <code>projects</code> to <code>users</code> and drops prereleases only for users who have opted out. The setting is a checkbox in the existing Settings dialog, and <code>Checkbox</code> was already registered in <code>main.js</code>, so this adds no new dependency.</p>
<p dir="auto">Two things I checked rather than assumed, since the query is the part that can silently do the wrong thing.</p>
<p dir="auto">The migration is safe on an existing database. I ran it against MariaDB 11 with users created before the column existed, and they all came out with <code>notifyPrerelease=1</code>, so an upgrade is a no-op until someone unticks the box. <code>down</code> drops the column cleanly with no row loss.</p>
<p dir="auto">The filter selects what it should. With two users, one opted in and one opted out, each starring a project with one stable release and one prerelease pending, the new query returns the opted-in user's stable release and prerelease, and only the stable release for the opted-out user. An already-notified release stays excluded as before.</p>
<p dir="auto">One behaviour worth flagging because it is a design choice and not an accident. A suppressed prerelease keeps <code>notified=FALSE</code> forever, so if a user later ticks the box back on, the prereleases published while it was off will be delivered at that point. Leaving them pending is what makes the setting reversible without losing anything, but if you would rather they were marked notified and silently dropped, that is a one-line change and I am happy to redo it either way.</p>
<p dir="auto">I also corrected a small inconsistency in passing. <code>migrations/current_schema.sql</code> declares the releases column as <code>prereleases</code> while the migration <code>20230926130714</code> and every code path use <code>prerelease</code>. The file is marked "Only for reference" so nothing was broken by it, but it is exactly the file someone reads before writing a patch like this one, so it seemed worth fixing while I was there. Say the word if you would prefer that split into its own commit.</p>
<pre><code class="language-diff">diff --git a/backend/database.js b/backend/database.js
index 57e6918..8960c5c 100644
--- a/backend/database.js
+++ b/backend/database.js
@@ -123,16 +123,23 @@ async function projectsRemove(projectId) {
     await db.query('DELETE FROM projects WHERE id=?', [ projectId ]);
 }
 
+function userPostprocess(u) {
+    u.notifyPrerelease = !!u.notifyPrerelease;
+    return u;
+}
+
 async function usersList() {
     const [result] = await db.query('SELECT * FROM users', []);
-    return result;
+    return result.map(userPostprocess);
 }
 
 async function usersAdd(user) {
     assert.strictEqual(typeof user, 'object');
 
-    await db.query('INSERT INTO users (id, email, githubToken) VALUES (?, ?, ?)',
-        [ user.id, user.email, user.githubToken ]);
+    if (typeof user.notifyPrerelease !== 'boolean') user.notifyPrerelease = true;
+
+    await db.query('INSERT INTO users (id, email, githubToken, notifyPrerelease) VALUES (?, ?, ?, ?)',
+        [ user.id, user.email, user.githubToken, user.notifyPrerelease ]);
 
     return user;
 }
@@ -143,16 +150,17 @@ async function usersGet(userId) {
     const [result] = await db.query('SELECT * FROM users WHERE id=?', [ userId ]);
     if (!result.length) throw new Error('no such user');
 
-    return result[0];
+    return userPostprocess(result[0]);
 }
 
-async function usersUpdate(userId, githubToken, email) {
+async function usersUpdate(userId, githubToken, email, notifyPrerelease) {
     assert.strictEqual(typeof userId, 'string');
     assert.strictEqual(typeof githubToken, 'string');
     assert.strictEqual(typeof email, 'string');
+    assert.strictEqual(typeof notifyPrerelease, 'boolean');
 
-    let args = [ githubToken, email, userId ];
-    let query = 'UPDATE users SET githubToken=?,email=? WHERE id=?';
+    let args = [ githubToken, email, notifyPrerelease, userId ];
+    let query = 'UPDATE users SET githubToken=?,email=?,notifyPrerelease=? WHERE id=?';
 
     await db.query(query, args);
 }
@@ -186,6 +194,10 @@ async function releasesUpdate(releaseId, data) {
 }
 
 async function releasesListAllPending() {
-    const [result] = await db.query('SELECT * FROM releases WHERE notified=FALSE', []);
+    // a prerelease is only pending for users who have not opted out of prerelease notifications
+    const [result] = await db.query('SELECT releases.* FROM releases' +
+        ' JOIN projects ON projects.id=releases.projectId' +
+        ' JOIN users ON users.id=projects.userId' +
+        ' WHERE releases.notified=FALSE AND (releases.prerelease=FALSE OR users.notifyPrerelease=TRUE)', []);
     return result;
 }
diff --git a/backend/routes.js b/backend/routes.js
index 90c87ce..f21ee85 100644
--- a/backend/routes.js
+++ b/backend/routes.js
@@ -61,7 +61,7 @@ async function auth(req, res, next) {
     // update email if changed
     if (user.email !== req.oidc.user.email) {
         try {
-            await database.users.update(user.id, user.githubToken, req.oidc.user.email);
+            await database.users.update(user.id, user.githubToken, req.oidc.user.email, user.notifyPrerelease);
             user.email = req.oidc.user.email;
         } catch (e) {
             console.error('Failed to update email for user.', user, e);
@@ -84,6 +84,9 @@ async function profileUpdate(req, res, next) {
 
     const githubToken = req.body.githubToken || '';
 
+    // absent means "leave unchanged", so a client that does not know about this field cannot reset it
+    const notifyPrerelease = 'notifyPrerelease' in req.body ? !!req.body.notifyPrerelease : req.user.notifyPrerelease;
+
     try {
         await github.verifyToken(githubToken);
     } catch (error) {
@@ -91,11 +94,12 @@ async function profileUpdate(req, res, next) {
     }
 
     try {
-        await database.users.update(req.user.id, githubToken, req.user.email);
+        await database.users.update(req.user.id, githubToken, req.user.email, notifyPrerelease);
     } catch (error) {
         return next(new HttpError(500, error));
     }
     req.user.githubToken = githubToken;
+    req.user.notifyPrerelease = notifyPrerelease;
 
     next(new HttpSuccess(202, {}));
 
diff --git a/frontend/App.vue b/frontend/App.vue
index c259601..f200eba 100644
--- a/frontend/App.vue
+++ b/frontend/App.vue
@@ -40,6 +40,10 @@
               &lt;br/&gt;
               &lt;a href="https://github.com/settings/tokens/new?description=ReleaseBell" target="_blank" style="margin-top: 10px;"&gt;Generate a GitHub API token&lt;/a&gt;
             &lt;/div&gt;
+            &lt;div class="form-field"&gt;
+              &lt;Checkbox id="notifyPrereleaseInput" v-model="settingsDialog.notifyPrerelease" :binary="true"/&gt;
+              &lt;label for="notifyPrereleaseInput" style="margin-left: 10px;"&gt;Notify me about prereleases&lt;/label&gt;
+            &lt;/div&gt;
           &lt;/div&gt;
         &lt;/form&gt;
         &lt;template #footer&gt;
@@ -151,6 +155,7 @@ export default {
         visible: false,
         busy: false,
         error: '',
+        notifyPrerelease: true,
         data: {}
       },
     };
@@ -198,6 +203,7 @@ export default {
     },
     onShowSettingsDialog() {
       this.settingsDialog.githubToken = this.user.githubToken;
+      this.settingsDialog.notifyPrerelease = this.user.notifyPrerelease !== false;
       this.settingsDialog.error = '';
       this.settingsDialog.visible = true;
     },
@@ -228,7 +234,7 @@ export default {
       this.settingsDialog.error = '';
 
       try {
-        await superagent.post(`${API_ORIGIN}/api/v1/profile`).send({ githubToken: this.settingsDialog.githubToken });
+        await superagent.post(`${API_ORIGIN}/api/v1/profile`).send({ githubToken: this.settingsDialog.githubToken, notifyPrerelease: this.settingsDialog.notifyPrerelease });
       } catch (error) {
         if (error.status === 402) {
           document.getElementById('githubTokenInput').focus();
@@ -241,6 +247,8 @@ export default {
         return;
       }
 
+      this.user.notifyPrerelease = this.settingsDialog.notifyPrerelease;
+
       this.settingsDialog.busy = false;
       this.settingsDialog.visible = false;
     }
diff --git a/migrations/20260804120000-add-user-notify-prerelease.js b/migrations/20260804120000-add-user-notify-prerelease.js
new file mode 100644
index 0000000..28f2ef2
--- /dev/null
+++ b/migrations/20260804120000-add-user-notify-prerelease.js
@@ -0,0 +1,9 @@
+'use strict';
+
+exports.up = function(db, callback) {
+  db.runSql('ALTER TABLE users ADD COLUMN notifyPrerelease BOOLEAN DEFAULT 1', callback);
+};
+
+exports.down = function(db, callback) {
+    db.runSql('ALTER TABLE users DROP COLUMN notifyPrerelease', callback);
+};
diff --git a/migrations/current_schema.sql b/migrations/current_schema.sql
index 76bfcd6..8e2ecb6 100644
--- a/migrations/current_schema.sql
+++ b/migrations/current_schema.sql
@@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS users(
     id VARCHAR(128) NOT NULL UNIQUE,
     email VARCHAR(512) NOT NULL,
     githubToken VARCHAR(512) NOT NULL DEFAULT "",
+    notifyPrerelease BOOLEAN DEFAULT true,
 
     PRIMARY KEY(id));
 
@@ -25,7 +26,7 @@ CREATE TABLE IF NOT EXISTS releases(
     version VARCHAR(512) NOT NULL,
     body TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
     notified BOOLEAN DEFAULT false,
-    prereleases BOOLEAN DEFAULT false,
+    prerelease BOOLEAN DEFAULT false,
     createdAt BIGINT NOT NULL,
 
     FOREIGN KEY(projectId) REFERENCES projects(id),
</code></pre>
<p dir="auto">On the per-project override I mentioned earlier: I have deliberately left it out of this patch to keep the diff to the thing you agreed to. It is still the better shape long term, because the common case is really "I want betas for this one project I am actually testing, and stable only for the other thirty". That would want a nullable <code>notifyPrerelease</code> on <code>projects</code> inheriting from the user setting when unset, which is a bigger change to the project list UI than to the backend. Happy to follow up with it as a second patch once this one is in, if you want it.</p>
]]></description><link>https://forum.cloudron.io/post/127679</link><guid isPermaLink="true">https://forum.cloudron.io/post/127679</guid><dc:creator><![CDATA[LoudLemur]]></dc:creator><pubDate>Tue, 04 Aug 2026 07:26:48 GMT</pubDate></item><item><title><![CDATA[Reply to ReleaseBell: option to suppress prerelease notifications on Tue, 04 Aug 2026 00:15:12 GMT]]></title><description><![CDATA[<p dir="auto">I think this is a good idea. <a class="plugin-mentions-user plugin-mentions-a" href="/user/loudlemur" aria-label="Profile: loudlemur">@<bdi>loudlemur</bdi></a> feel free to post a patch and I can merge it.</p>
]]></description><link>https://forum.cloudron.io/post/127675</link><guid isPermaLink="true">https://forum.cloudron.io/post/127675</guid><dc:creator><![CDATA[girish]]></dc:creator><pubDate>Tue, 04 Aug 2026 00:15:12 GMT</pubDate></item><item><title><![CDATA[Reply to ReleaseBell: option to suppress prerelease notifications on Sun, 02 Aug 2026 18:02:08 GMT]]></title><description><![CDATA[<p dir="auto">A better thing to look into would be <a href="https://docs.renovatebot.com/" target="_blank" rel="noopener noreferrer nofollow ugc">https://docs.renovatebot.com/</a>. I think i have seen renovate config files in the cloudron app repositories as well in the past. With renovate you can get automatic merge request for the changed dependencies.</p>
<p dir="auto">A working example can be found in this repo as well <a href="https://git.9wd.eu/apps/cloudron-cypht" target="_blank" rel="noopener noreferrer nofollow ugc">https://git.9wd.eu/apps/cloudron-cypht</a></p>
]]></description><link>https://forum.cloudron.io/post/127626</link><guid isPermaLink="true">https://forum.cloudron.io/post/127626</guid><dc:creator><![CDATA[fbartels]]></dc:creator><pubDate>Sun, 02 Aug 2026 18:02:08 GMT</pubDate></item></channel></rss>