Skip to content

Commit dae2780

Browse files
committed
Drive releases off a metadata files instead of manually
This, together with the previous cve changes, makes it possible to do a complete release in just one "git push", instead of a combination of editing files and timing it with making changes in the database manually in the right order. In passing also dynamically drive the text about when it's time to upgrade from the eoldate listed in the database, and start showing the warning at 180 days.
1 parent 7c6b66c commit dae2780

15 files changed

Lines changed: 404 additions & 81 deletions

File tree

data/releases/2026-05-14.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
date: 2026-05-14
2+
versions:
3+
- 18.3
4+
- 17.9
5+
- 16.13
6+
- 15.17
7+
- 14.22
8+
news:
9+
id: 3297
10+
cve:
11+
- 2026-6472
12+
- 2026-6473
13+
- 2026-6474
14+
- 2026-6475
15+
- 2026-6476
16+
- 2026-6477
17+
- 2026-6478
18+
- 2026-6479
19+
- 2026-6575
20+
- 2026-6637
21+
- 2026-6638

data/releases/2026-06-04.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
date: 2026-06-04
2+
versions:
3+
- 19beta1
4+
news:
5+
id: 3313

data/releases/release.template

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# This is a template file with all keys that can do in a release yaml file
2+
date: 2026-06-04 # Date of the release, in yyyy-mm-dd format
3+
versions: # List of which versions are being released. minor expects >= 1 entry, all others expect 1
4+
- 19.0 # Each version should always have the full <major>.<minor> format (must be .0 for major release), or <major>beta<num> (etc for alpha and rc)
5+
news:
6+
id: 1656 # id of the news article with the release announcement
7+
announcetext: | # IFF this is type=major or override, the actual HTML to use in the text
8+
<p>
9+
This is the big text to be <b>overridden</b>.
10+
</p>
11+
nooldestwarning: true # Set to true to exclude the warning to upgrade from the oldest version

pgweb/core/models.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from pgweb.util.misc import varnish_purge
55

66
import base64
7+
from datetime import date, timedelta
78
from decimal import Decimal
89

910
from pgweb.util.moderation import TwostateModerateModel
@@ -36,6 +37,10 @@ def __str__(self):
3637
def versionstring(self):
3738
return self.buildversionstring(self.latestminor)
3839

40+
@property
41+
def versionstring_spaced(self):
42+
return self.buildversionstring(self.latestminor, True)
43+
3944
@property
4045
def numtree(self):
4146
# Return the proper numeric tree version, taking into account that PostgreSQL 10
@@ -61,9 +66,11 @@ def relnotes(self):
6166
# Should never happen so return something broken
6267
return 'x'
6368

64-
def buildversionstring(self, minor):
69+
def buildversionstring(self, minor, spaced=False):
6570
if not self.testing:
6671
return "%s.%s" % (self.numtree, minor)
72+
elif spaced:
73+
return "%s %s %s" % (self.numtree, TESTING_SHORTSTRING[self.testing].capitalize(), minor)
6774
else:
6875
return "%s%s%s" % (self.numtree, TESTING_SHORTSTRING[self.testing], minor)
6976

@@ -89,6 +96,10 @@ def save(self, update_fields=None):
8996
# free to save this one.
9097
super(Version, self).save(update_fields=update_fields)
9198

99+
@property
100+
def soon_eol(self):
101+
return self.supported and self.eoldate < date.today() + timedelta(days=180)
102+
92103
class Meta:
93104
ordering = ('-tree', )
94105

pgweb/core/views.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from pgweb.util.sitestruct import get_all_pages_struct
3333
from pgweb.mailqueue.util import send_simple_mail
3434
from pgweb.account.views import OAUTH_PASSWORD_STORE
35+
from pgweb.release.util import CurrentRelease
3536

3637
# models needed for the pieces on the frontpage
3738
from pgweb.news.models import NewsArticle, NewsTag
@@ -68,15 +69,18 @@ def home(request):
6869
community_event_queryset = event_base_queryset.filter(badged=True).order_by('enddate', 'startdate')[:(7 - other_events.count())]
6970
# now, return all the events in one unioned array!
7071
events = community_event_queryset.union(other_events).order_by('enddate', 'startdate').all()
71-
versions = Version.objects.filter(supported=True)
72+
versions = list(Version.objects.filter(supported=True))
7273
planet = ImportedRSSItem.objects.filter(feed__internalname="planet").order_by("-posttime")[:9]
7374

75+
release = CurrentRelease.get()
76+
7477
return render(request, 'index.html', {
7578
'title': 'The world\'s most advanced open source database',
7679
'news': news,
7780
'events': events,
7881
'versions': versions,
7982
'planet': planet,
83+
'release': release,
8084
'og': {
8185
'url': '/',
8286
'author': 'PostgreSQL Global Development Group',

pgweb/release/README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Release flow
2+
3+
The release flow is triggered automatically after each migration, and picks up the latest
4+
file in data/releases/ (ordered by filename, must end in `.yaml`) and makes a release
5+
accordingly.
6+
7+
## User steps
8+
9+
* Create a news embargo
10+
* Create a news article with all details, leaving as unapproved
11+
* Create a `data/releases/<date>.yaml` file
12+
* If this is the first alpha/beta/rc, create a Version record for this version
13+
* `git push`
14+
15+
### Version mixing
16+
17+
Normally, on a *major release* or on *first beta version*, a single
18+
release should be in the `yaml` file. This will trigger a "full"
19+
announcement on the website. In these cases, set the `announcetext`
20+
key in the yaml to the full details.
21+
22+
If more than one version is listed, it is *always* treated as a set of
23+
minor releases. This can either be a general set of scheduled minor
24+
releases, or it can be a combination of a beta and minors for
25+
example. Even a major version can be included, but it will be treated
26+
as a minor version on the website.
27+
28+
## Resulting actions
29+
30+
* Release date is adjusted to date from yaml file
31+
* Latest minor version on the Version object is adjusted to the one
32+
from the `yaml` file (note that this happens even if it's
33+
decreased!) This includes setting alpha/beta/rc number on testing
34+
releases.
35+
* If the previous version was a testing one and the new one is a `.0`
36+
major release, the testing flag is turned off and the new `.0` is
37+
set to both current and supported. Note that if a release directly
38+
moves from alpha/beta/rc to a minor (such as a `.1`), only the minor
39+
numbers will be updated and it will not be set to supported/current
40+
(because this is a non-standard workflow).
41+
* All CVEs listed in the `yaml` (normally only on minor, but
42+
works for all) set to public and has their announcement linked to
43+
the news article specified
44+
* News article has date set to date from yaml file and is
45+
approved, bypassing embargo, and emailed

pgweb/release/apps.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from django.apps import AppConfig
2+
from django.db.models.signals import post_migrate
3+
4+
5+
def do_post_migrate(sender, **kwargs):
6+
from .migrate import do_migrate
7+
do_migrate()
8+
9+
10+
class ReleaseAppConfig(AppConfig):
11+
name = "pgweb.release"
12+
13+
def ready(self):
14+
post_migrate.connect(do_post_migrate, sender=self)

pgweb/release/migrate.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from django.db import transaction
2+
3+
from datetime import date
4+
5+
from pgweb.core.models import Version, TESTING_SHORTSTRING
6+
from pgweb.news.models import NewsArticle, PinnedNewsArticle
7+
from pgweb.security.models import SecurityPatch
8+
from pgweb.util.moderation import ModerationState
9+
from pgweb.util.versions import ParsedVersion
10+
11+
from pgweb.release.util import CurrentRelease
12+
13+
14+
@transaction.atomic
15+
def do_migrate():
16+
# Runs after migrations. Process the current release into being an actual release, if we can.
17+
release = CurrentRelease.get()
18+
19+
if release.get('noprocess', False):
20+
print("Noprocess flag set on release, not processing")
21+
return
22+
23+
if not isinstance(release['date'], date):
24+
raise Exception("Incorrect format of date {}".format(release['date']))
25+
26+
if release['type'] in ('major', 'override') and not release.get('announcetext', None):
27+
raise Exception("Release type {} must specify an announcetext".format(release['type']))
28+
29+
newsid = release.get('news', {}).get('id', None)
30+
if not newsid:
31+
print("No news specified for release, cannot approve news or connect CVEs")
32+
news = None
33+
else:
34+
try:
35+
news = NewsArticle.objects.get(pk=newsid)
36+
except NewsArticle.DoesNotExist:
37+
print("Could not find news article with id {}, cannot approve news or connect CVEs".format(release['news']['id']))
38+
news = None
39+
40+
# Update latest available version
41+
for ver in release['versions']:
42+
try:
43+
v = Version.objects.only('tree', 'latestminor', 'reldate', 'testing').get(tree=ver.major)
44+
uf = []
45+
if v.latestminor != ver.minor:
46+
v.latestminor = ver.minor
47+
uf.append('latestminor')
48+
print("Set latestminor={} for version {}".format(v.latestminor, v.numtree))
49+
if v.reldate != release['date']:
50+
v.reldate = release['date']
51+
uf.append('reldate')
52+
print("Set reldate={} for version {}".format(v.reldate, v.numtree))
53+
54+
if ver.testing and v.testing != ver.testing:
55+
v.testing = ver.testing
56+
v.current = False
57+
v.supported = False
58+
uf.extend(['testing', 'current', 'supported'])
59+
print("Set testing={}, current=False, supported=False for version {}".format(ver.type, v.numtree))
60+
61+
# If we are transitioning beta -> release, set testing and supported flags.
62+
if v.testing != 0 and ver.type == 'major':
63+
v.testing = 0
64+
v.supported = True
65+
v.current = True
66+
uf.extend(['testing', 'supported', 'current'])
67+
print("Set testing=Release, supported=True and current=True for major version {}".format(v.numtree))
68+
69+
if uf:
70+
v.save(update_fields=list(set(uf)))
71+
except Version.DoesNotExist:
72+
print("Could not find version tree {}, cannot update latest minor".format(ver.major))
73+
74+
# If there are any CVEs attached to this release, add the news article to them if there is one
75+
if news:
76+
for cve in release.get('cve', []):
77+
try:
78+
patch = SecurityPatch.objects.get(cve=cve)
79+
patch.newspost = news
80+
patch.public = True
81+
patch.save(update_fields=['newspost', 'public'])
82+
print("Attached news article {} to CVE-{}".format(news.id, cve))
83+
except SecurityPatch.DoesNotExist:
84+
print("Could not find CVE {}, cannot connect to announcement")
85+
86+
# If the news article isn't approved yet, bypass approval and do it!
87+
if news:
88+
if news.modstate != ModerationState.APPROVED:
89+
print("Approving news item {}".format(news.id))
90+
news.modstate = ModerationState.APPROVED
91+
news.date = release['date']
92+
news.save(update_fields=['modstate', 'date'])
93+
print("Sending announcement email for news item")
94+
news.on_approval(None)
95+
96+
pa = PinnedNewsArticle.objects.all()[0]
97+
if pa.pinnedarticle != news:
98+
print("Pinning news item {}".format(news.id))
99+
pa.pinnedarticle = news
100+
pa.save(update_fields=['pinnedarticle'])

pgweb/release/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Empty file needed for post_migrate() to run

pgweb/release/util.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import os
2+
import os.path
3+
4+
import yaml
5+
6+
from pgweb.util.versions import ParsedVersion
7+
8+
9+
def determine_release_type(release):
10+
if any(v for v in release['versions'] if v.type == 'major'):
11+
if len(release['versions']) != 1:
12+
raise Exception("Major versions should have exactly one version listed")
13+
return 'major'
14+
elif len(release['versions']) == 1:
15+
# A single release, so just copy the type
16+
return release['versions'][0].type
17+
else:
18+
# Any case of >1 release is treated like a minor
19+
return 'minor'
20+
21+
22+
class Release:
23+
def __init__(self):
24+
self._current_release = None
25+
self._current_release_file = None
26+
self._current_release_file_timestamp = None
27+
28+
def get(self):
29+
from pgweb.news.models import NewsArticle
30+
31+
currname = self._get_current_release_filename()
32+
33+
if self._current_release_file and self._current_release_file_timestamp and self._current_release_file == currname and self._current_release_file_timestamp == os.stat(currname).st_mtime:
34+
# We have it loaded and there has been no changes
35+
return self._current_release
36+
37+
# Either we haven't loaded yet, or it has changed, so reload
38+
self._current_release_file = currname
39+
self._current_release_file_timestamp = os.stat(currname).st_mtime
40+
with open(self._current_release_file) as f:
41+
self._current_release = yaml.load(f, Loader=yaml.SafeLoader)
42+
self._current_release['versions'] = [ParsedVersion(str(v)) for v in self._current_release['versions']]
43+
try:
44+
self._current_release['type'] = determine_release_type(self._current_release)
45+
except Exception:
46+
self._current_release['type'] = 'unknown'
47+
newsid = self._current_release.get('news', {}).get('id', None)
48+
if newsid:
49+
try:
50+
self._current_release['news']['url'] = NewsArticle.objects.only('id', 'title').get(pk=self._current_release['news']['id']).permanenturl
51+
except NewsArticle.DoesNotExist:
52+
self._current_release['news']['url'] = '/about/news/{}/'.format(self._current_release['news']['id'])
53+
54+
return self._current_release
55+
56+
def _get_current_release_filename(self):
57+
basepath = os.path.abspath(os.path.join(__file__, '../../../data/releases'))
58+
return os.path.join(basepath, max(f for f in os.listdir(basepath) if f.endswith('.yaml')))
59+
60+
61+
CurrentRelease = Release()

0 commit comments

Comments
 (0)