Skip to content
This repository was archived by the owner on Sep 9, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def cli():
# Add the commands:
cli.add_command(dev.lint)
cli.add_command(dev.setup)
cli.add_command(dev.update)

if __name__ == '__main__':
# Configure logging:
Expand Down
1 change: 1 addition & 0 deletions dev/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@
#

from .formatter import *
from .update import *
from .lint import *
from .setup import *
56 changes: 56 additions & 0 deletions dev/update.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-

#
# Copyright (c) 2026 Red Hat Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
# specific language governing permissions and limitations under the License.
#

import hashlib
import logging

import click

from . import dirs


@click.group()
def update() -> None:
"""
Updates generated project artifacts.
"""


@update.command(name="hashes")
def hashes() -> None:
"""
Updates the database migrations hash.
"""
# Compute the hash of the migration files:
migrations_dir = dirs.project() / "internal" / "database" / "migrations"
migration_files = migrations_dir.glob("*.up.sql")
migration_files_sorted = sorted(migration_files)
computed_hash_source = "".join(migration_file.name + "\n" for migration_file in migration_files_sorted)
computed_hash_source_bytes = computed_hash_source.encode()
computed_hash_bytes = hashlib.sha256(computed_hash_source_bytes).digest()
computed_hash_text = computed_hash_bytes.hex()

# Read the current hash from the file:
hash_file = migrations_dir.parent / "migrations.sha256"
stored_hash_text = hash_file.read_text().strip() if hash_file.exists() else ""

# Check if the hash is already up to date:
if stored_hash_text == computed_hash_text:
logging.info("Database migrations hash is already up to date")
return

# Update the hash file:
hash_file.write_text(computed_hash_text + "\n")
logging.info("Database migrations hash updated to '%s'", computed_hash_text)
15 changes: 15 additions & 0 deletions internal/database/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Database

This directory contains the database layer of the service. The `migrations` subdirectory contains
the numbered `.up.sql` files that are applied sequentially to evolve the database schema.

## Migration file hash

The `migrations.sha256` file contains a SHA-256 digest of the sorted list of `.up.sql` filenames.
Its purpose is to prevent two pull requests from accidentally introducing migrations with the same
number: since both PRs would write a different hash, git will flag a merge conflict forcing the
second author to renumber their migration before merging.

If you add a new migration, run `uv run dev.py update hashes` and commit the updated
`migrations.sha256` file alongside your new migration file. If you forget, the unit test that
verifies the hash will fail and indicate the mismatch.
40 changes: 40 additions & 0 deletions internal/database/database_migrations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ language governing permissions and limitations under the License.
package database

import (
"bytes"
"crypto/sha256"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
Expand Down Expand Up @@ -92,6 +95,43 @@ var _ = Describe("Migrations", func() {
Expect(violations).To(BeEmpty(), "migration filenames violate naming convention: %v", violations)
})

It("Has an up-to-date migrations hash", func() {
// Read the stored hash:
storedHashFile, err := filepath.Abs("migrations.sha256")
Expect(err).ToNot(HaveOccurred())
storedHashBytes, err := os.ReadFile(storedHashFile)
Expect(err).ToNot(HaveOccurred())
storedHashText := strings.TrimSpace(string(storedHashBytes))

// Get the names of the migration migrationFiles and sort them:
migrationFiles, err := filepath.Glob("migrations/*.up.sql")
Expect(err).ToNot(HaveOccurred())
Expect(migrationFiles).ToNot(BeEmpty())
migrationNames := make([]string, len(migrationFiles))
for i, file := range migrationFiles {
migrationNames[i] = filepath.Base(file)
}
sort.Strings(migrationNames)

// Compute the SHA-256 hash of the migration file list:
computedHashSource := &bytes.Buffer{}
for _, migrationName := range migrationNames {
_, err := fmt.Fprintf(computedHashSource, "%s\n", migrationName)
Expect(err).ToNot(HaveOccurred())
}
computedHashBytes := sha256.Sum256(computedHashSource.Bytes())
computedHashText := fmt.Sprintf("%x", computedHashBytes)

// Compare the computed hash with the stored hash:
if computedHashText != storedHashText {
Fail(fmt.Sprintf(
"Database migrations hash in '%s' is outdated, should be '%s' but is '%s', update "+
"it manually or run 'uv run dev.py update hashes' to update it automatically",
storedHashFile, computedHashText, storedHashText,
))
}
})

It("Has no unexpected gaps in migration numbering", func() {
files, err := filepath.Glob("migrations/*.up.sql")
Expect(err).ToNot(HaveOccurred())
Expand Down
1 change: 1 addition & 0 deletions internal/database/migrations.sha256
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ab88e4e53f1ffd9b1694b90b285f4e791070cee7f086c4045eb002747b6d451e
Loading