Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ee9fe4b
Workflow to tag and build containers (issue #954)
k1o0 Jul 21, 2026
da61f04
Remove iblalyx from containers (issue #1017)
k1o0 Jul 21, 2026
a830cd1
Port deploy-time OOM/log config into deploy/docker for CI-built image
oliche Jul 22, 2026
58260fa
Make deploy/docker image production-capable (parity with iblsre)
oliche Jul 22, 2026
44ebc14
Merge dev into release_workflow (brings #1013 pagination cap)
oliche Jul 22, 2026
23f7e79
Fix flaky test
k1o0 Jul 22, 2026
b60ff39
Restructure deploy/ into app and editable, add production compose (C1)
oliche Jul 22, 2026
4719dd4
Split release and image-build workflows (C2)
oliche Jul 22, 2026
73f226e
Drop iblalyx requirements install from base compose command
oliche Jul 22, 2026
ac4356b
Drop iblalyx bind-mount volumes from base compose
oliche Jul 22, 2026
d5db32f
Gate image push on an ansible smoke test (C3)
oliche Jul 22, 2026
f359b7e
Make production image boot and pass the smoke test end-to-end
oliche Jul 23, 2026
fbda39b
Document the CI/CD image build flow in deployment docs
oliche Jul 23, 2026
c99c861
Bump django from 5.2.14 to 5.2.15 (#1012)
dependabot[bot] Jul 23, 2026
f51f9f1
Bump pillow from 12.2.0 to 12.3.0 (#1019)
dependabot[bot] Jul 23, 2026
15f228e
Register file validation (#1014)
k1o0 Jul 23, 2026
cb05c4f
Data Noice model for information about datasets (#1007)
k1o0 Jul 23, 2026
5a37291
Remove unknown data set type from test dump (#1021)
k1o0 Jul 23, 2026
8246ee5
Merge branch 'dev' into release_workflow
oliche Jul 23, 2026
acccaf7
Make image build safely testable via dev* dry-run tags
oliche Jul 23, 2026
9659778
Merge pull request #1018 from cortex-lab/release_workflow
oliche Jul 23, 2026
14a893f
Bump version to 3.6.0 and update changelog
oliche Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .github/workflows/build-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: Build and push images

# Builds the alyx docker images for a tag, smoke-tests the built image, and pushes only
# if the test passes. github.ref_name (the tag name) is the image tag and the alyx ref
# that gets built. Triggered by:
# - a release version tag `N.N.N` (pushed by release.yml, or by a human for a rebuild)
# -> build + smoke test + push `<version>` and `latest` to Docker Hub;
# - a `dev*` tag (dry run to exercise this pipeline before a real release)
# -> build + smoke test only, NO Docker Hub login and NO push;
# - workflow_dispatch (available only once this file is on the default branch).
# NB: tag triggers are NOT branch-scoped — git tags do not belong to a branch, and GitHub
# offers no branch filter for tag events. The tag-name patterns below are the restriction.
on:
push:
tags:
- '*.*.*' # release versions -> build + push
- 'dev*' # dry run -> build + smoke test, no push
workflow_dispatch:

jobs:
docker:
runs-on: ubuntu-latest
env:
VERSION: ${{ github.ref_name }}
BASE: internationalbrainlab/alyx_apache_base
MAIN: internationalbrainlab/alyx_apache
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.ref_name }}

# Plain docker build (native linux/amd64) so the main image's FROM resolves the
# locally-built base without pushing first. Nothing leaves the runner until the
# smoke test below passes.
- name: Build base image
run: |
docker build -f deploy/app/docker/Dockerfile_base \
--build-arg alyx_branch="$VERSION" \
-t "$BASE:latest" -t "$BASE:$VERSION" \
deploy/app/docker

- name: Build main image
run: |
docker build -f deploy/app/docker/Dockerfile --no-cache \
--build-arg alyx_branch="$VERSION" \
-t "$MAIN:latest" -t "$MAIN:$VERSION" \
deploy/app/docker

- name: Install ansible
run: pipx install ansible-core

- name: Smoke-test the built image
run: ansible-playbook deploy/app/test/test-deploy-web.yaml

# Dry-run tags (dev*) stop here: the pipeline is validated (build + smoke test) without
# touching Docker Hub. Only real version tags log in and push.
- name: Log in to Docker Hub
if: ${{ !startsWith(github.ref_name, 'dev') }}
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Push images
if: ${{ !startsWith(github.ref_name, 'dev') }}
run: |
docker push "$BASE:latest"
docker push "$BASE:$VERSION"
docker push "$MAIN:latest"
docker push "$MAIN:$VERSION"
4 changes: 2 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ jobs:
sudo chmod 666 /var/log/alyx/django.log
cd alyx
cp ./alyx/environment_template.env ./alyx/.env
cp ../deploy/docker/settings-deploy.py alyx/settings.py
cp ../deploy/docker/settings_lab-deploy.py alyx/settings_lab.py
cp ../deploy/app/docker/settings-deploy.py alyx/settings.py
cp ../deploy/app/docker/settings_lab-deploy.py alyx/settings_lab.py
python manage.py collectstatic --noinput --link
coverage run manage.py test -n
coveralls --service=github
Expand Down
68 changes: 68 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: Release

# Runs once the CI workflow succeeds on master. If the __version__ in alyx/alyx/__init__.py
# has been bumped since the last tag, cuts a git tag + GitHub release for it, then triggers
# the image-build workflow for that tag. Image building itself lives in build-image.yml.
on:
workflow_run:
workflows: ["CI, approaching CD"]
types: [completed]
branches: [master]

permissions:
contents: write
actions: write # allows dispatching build-image.yml

jobs:
check-version:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
should_release: ${{ steps.check_tag.outputs.should_release }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0

- name: Get version from alyx/alyx/__init__.py
id: get_version
run: |
VERSION=$(python3 -c "import re; print(re.search(r\"__version__ = '([^']+)'\", open('alyx/alyx/__init__.py').read()).group(1))")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"

- name: Check whether this version has already been tagged
id: check_tag
run: |
if git ls-remote --tags origin | grep -qE "refs/tags/${{ steps.get_version.outputs.version }}$"; then
echo "should_release=false" >> "$GITHUB_OUTPUT"
else
echo "should_release=true" >> "$GITHUB_OUTPUT"
fi

release:
needs: check-version
if: needs.check-version.outputs.should_release == 'true'
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.check-version.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha }}

- name: Create tag and GitHub release
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$VERSION" -m "Release $VERSION"
git push origin "$VERSION"
gh release create "$VERSION" --title "$VERSION" --generate-notes

# A tag pushed by GITHUB_TOKEN does not trigger build-image.yml's push-tags event
# (GitHub suppresses it to avoid recursion), so dispatch the build explicitly.
# workflow_dispatch is exempt from that suppression.
- name: Trigger image build for the new tag
run: gh workflow run build-image.yml --ref "$VERSION"
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [3.6.0]

### Added

- `DataNotice` model to attach information/notices to datasets (#1007)
- Registered-file validation (#1014)
- CI/CD release pipeline: bumping `__version__` on `master` cuts a git tag + GitHub release
and builds/pushes the `alyx_apache[_base]` docker images, gated behind an ansible smoke test (#954)

### Changed

- The production docker image and compose are now built from this repository (`deploy/app/`) as
the single source of truth; `iblalyx` is no longer baked into the image (bind-mounted at deploy
time), and deploy orchestration (ansible, per-server overrides) lives in `iblsre` (#1017)
- Restructured `deploy/` into `app/` (production) and `editable/` (postgres-only for editable installs)
- Bump Django 5.2.14 → 5.2.15 (#1012) and Pillow 12.2.0 → 12.3.0 (#1019)

### Fixed

- Remove unknown dataset type from the test dump fixture (#1021)
- Flaky task-cleanup test (save within the datetime mock context)

## [3.5.1]

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion alyx/alyx/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
VERSION = __version__ = '3.5.1'
VERSION = __version__ = '3.6.0'
2 changes: 1 addition & 1 deletion alyx/alyx/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ def alyx_mail(to, subject, text=''):
'Imaging sessions', 'Fields of view']), # May add 'Imaging type'
('Data files', [
'Data repository types', 'Data repositories', 'Tasks', 'Data formats', 'Dataset types',
'Datasets', 'File records', 'Tags', 'Revisions', 'Downloads']),
'Datasets', 'File records', 'Tags', 'Revisions', 'Data notices', 'Downloads']),
('Subject genetics', [
'Lines', 'Strains', 'Alleles', 'Sequences', 'Sources', 'Species',
'Genotypes', 'Genotype tests', 'Zygosities', 'Zygosity tests']),
Expand Down
108 changes: 105 additions & 3 deletions alyx/data/admin.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
from django.db.models import Count, ProtectedError
from django.db.models import Count, Exists, OuterRef, ProtectedError
from django.contrib import admin, messages
from django.utils.html import format_html
from django_admin_listfilter_dropdown.filters import RelatedDropdownFilter, ChoiceDropdownFilter
from django_admin_listfilter_dropdown.filters import (
RelatedDropdownFilter,
ChoiceDropdownFilter,
SimpleDropdownFilter,
)
from rangefilter.filters import DateRangeFilter

from actions.models import Session
from subjects.models import Project
from .models import (DataRepositoryType, DataRepository, DataFormat, DatasetType,
Dataset, FileRecord, Download, Revision, Tag)
Dataset, FileRecord, Download, Revision, Tag, DataNotice)
from alyx.base import BaseAdmin, BaseInlineAdmin, DefaultListFilter, get_admin_url


Expand Down Expand Up @@ -231,6 +237,101 @@ def get_queryset(self, request):
return queryset


class DataNoticeAdmin(BaseAdmin):
fields = (
'name',
'description',
'importance',
'version_affected',
'affected_date_start',
'affected_date_end',
'datasets',
'created_by',
'created_datetime',
'json',
)
readonly_fields = ('created_datetime',)

class DatasetTagListFilter(SimpleDropdownFilter):
title = 'dataset tag'
parameter_name = 'dataset_tag'

def lookups(self, request, model_admin):
return Tag.objects.order_by('name').values_list('id', 'name')

def queryset(self, request, queryset):
"""Filter DataNotice queryset by dataset tag.

This filter avoids joining the full Dataset table directly.
"""
value = self.value()
if not value:
return queryset

notice_dataset_through = DataNotice.datasets.through
dataset_tag_through = Dataset.tags.through

matching_datasets = dataset_tag_through.objects.filter(tag_id=value).values('dataset_id')
matching_notice_datasets = notice_dataset_through.objects.filter(
datanotice_id=OuterRef('pk'), dataset_id__in=matching_datasets)

return queryset.annotate(_has_dataset_tag=Exists(matching_notice_datasets)).filter(
_has_dataset_tag=True)

class SessionProjectListFilter(SimpleDropdownFilter):
title = 'project'
parameter_name = 'project'

def lookups(self, request, model_admin):
notice_dataset_through = DataNotice.datasets.through
session_project_through = Session.projects.through

session_ids = Dataset.objects.filter(
id__in=notice_dataset_through.objects.values('dataset_id'),
session_id__isnull=False,
).values('session_id')
project_ids = session_project_through.objects.filter(
session_id__in=session_ids,
).values('project_id')

return Project.objects.filter(id__in=project_ids).order_by('name').values_list('id', 'name')

def queryset(self, request, queryset):
value = self.value()
if not value:
return queryset

notice_dataset_through = DataNotice.datasets.through
session_project_through = Session.projects.through

matching_sessions = session_project_through.objects.filter(project_id=value).values('session_id')
matching_datasets = Dataset.objects.filter(session_id__in=matching_sessions).values('id')
matching_notice_datasets = notice_dataset_through.objects.filter(
datanotice_id=OuterRef('pk'), dataset_id__in=matching_datasets)

return queryset.annotate(_has_project=Exists(matching_notice_datasets)).filter(
_has_project=True)

def has_change_permission(self, request, obj=None):
# DataNotice has no subject/session ownership; any non-public authenticated user may edit.
if request.user.is_public_user:
return False
return True

list_display = (
'name',
'importance',
'version_affected',
'affected_date_start',
'affected_date_end',
'created_by',
'created_datetime',
)
list_filter = (DatasetTagListFilter, SessionProjectListFilter)
search_fields = ('name', 'description', 'version_affected', 'created_by__username')
autocomplete_fields = ('datasets',)


admin.site.register(DataRepositoryType, DataRepositoryTypeAdmin)
admin.site.register(DataRepository, DataRepositoryAdmin)
admin.site.register(DataFormat, DataFormatAdmin)
Expand All @@ -240,3 +341,4 @@ def get_queryset(self, request):
admin.site.register(Download, DownloadAdmin)
admin.site.register(Revision, RevisionAdmin)
admin.site.register(Tag, TagAdmin)
admin.site.register(DataNotice, DataNoticeAdmin)
36 changes: 36 additions & 0 deletions alyx/data/migrations/0023_datanotice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Generated by Django 5.2.12 on 2026-06-22 11:27

import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('data', '0022_alter_datarepository_timezone'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='DataNotice',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(blank=True, help_text='Long name', max_length=255)),
('json', models.JSONField(blank=True, help_text='Structured data, formatted in a user-defined way', null=True)),
('description', models.TextField(blank=True)),
('importance', models.IntegerField(choices=[(50, 'Critical'), (40, 'Major'), (30, 'Minor'), (20, 'Insignificant')], default=20, help_text='50: CRITICAL / 40: MAJOR / 30: MINOR / 20: INSIGNIFICANT')),
('created_datetime', models.DateTimeField(auto_now_add=True)),
('version_affected', models.CharField(blank=True, max_length=64)),
('affected_date_start', models.DateField(blank=True, null=True)),
('affected_date_end', models.DateField(blank=True, null=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='data_notices', to=settings.AUTH_USER_MODEL)),
('datasets', models.ManyToManyField(blank=True, related_name='data_notices', to='data.dataset')),
],
options={
'ordering': ('-importance', '-created_datetime', 'name'),
},
),
]
Loading
Loading