Skip to content

Restyle Pinakes and complete mobile collections and catalogue fixes #1007

Restyle Pinakes and complete mobile collections and catalogue fixes

Restyle Pinakes and complete mobile collections and catalogue fixes #1007

name: Upgrade Smoke Test
# Simulates a real-world upgrade: installs the latest stable release, then
# applies the current branch's migrations on top. Catches migration regressions
# that would break existing installations.
on:
push:
branches: [main]
paths:
- 'installer/database/**'
- 'installer/classes/Installer.php'
- 'app/Models/AuthorRepository.php'
- 'app/Support/ContributorBackfill.php'
- 'app/Support/ContributorSync.php'
- 'app/Support/SearchIndexBuilder.php'
- 'app/Support/Updater.php'
- 'scripts/list-source-expectations.php'
- 'composer.json'
- 'composer.lock'
- 'version.json'
- '.github/workflows/ci-upgrade-smoke.yml'
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
attestations: read # Let `gh attestation verify` check the release archive's provenance.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
upgrade-smoke:
name: Upgrade from latest release → verify schema
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0@sha256:7dcddc01f13bab2f15cde676d44d01f61fc9f99fe7785e86196dfc07d358ae2b
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: pinakes_upgrade
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with:
php-version: '8.2'
extensions: mysqli, curl, zip, mbstring
coverage: none
- name: Install PHP dependencies
run: composer install --no-interaction --prefer-dist --no-progress --no-scripts
- name: Wait for MySQL
run: until mysqladmin ping -h"127.0.0.1" --silent; do sleep 1; done
# ── Step 1: Resolve the newest stable baseline older than target ───────
- name: Resolve upgrade baseline
id: release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
TARGET_VERSION=$(jq -er '.version | select(type == "string" and length > 0)' version.json)
gh api --paginate --slurp \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"repos/${GH_REPO}/releases?per_page=100" >/tmp/releases.json
TARGET_VERSION="$TARGET_VERSION" python3 <<'PY' >/tmp/release.json
import json
import os
import re
target = os.environ['TARGET_VERSION']
target_match = re.fullmatch(r'(\d+)\.(\d+)\.(\d+)(?:-(?:alpha|beta|rc)\.\d+)?', target)
if target_match is None:
raise SystemExit(f'Unsupported target version: {target}')
target_key = tuple(map(int, target_match.groups()))
pages = json.load(open('/tmp/releases.json', encoding='utf-8'))
candidates = []
for release in (item for page in pages for item in page):
match = re.fullmatch(r'v(\d+)\.(\d+)\.(\d+)', release.get('tag_name', ''))
if release.get('draft') or release.get('prerelease') or match is None:
continue
key = tuple(map(int, match.groups()))
if key < target_key:
candidates.append((key, release))
print(json.dumps(max(candidates, key=lambda item: item[0])[1] if candidates else {}))
PY
TAG=$(jq -r '.tag_name // empty' /tmp/release.json)
if [ -z "$TAG" ]; then
{
echo "skip=true"
echo "target_version=$TARGET_VERSION"
} >> "$GITHUB_OUTPUT"
echo "No stable release older than $TARGET_VERSION — running fresh-install checks only"
exit 0
fi
BASELINE_VERSION=${TAG#v}
ASSET_ID=$(jq -r '[.assets[] | select(.state == "uploaded" and (.name | endswith(".zip")))] | if length == 1 then .[0].id else empty end' /tmp/release.json)
ASSET_NAME=$(jq -r '[.assets[] | select(.state == "uploaded" and (.name | endswith(".zip")))] | if length == 1 then .[0].name else empty end' /tmp/release.json)
CHECKSUM_ID=$(jq -r '[.assets[] | select(.state == "uploaded" and (.name | endswith(".zip.sha256")))] | if length == 1 then .[0].id else empty end' /tmp/release.json)
CHECKSUM_NAME=$(jq -r '[.assets[] | select(.state == "uploaded" and (.name | endswith(".zip.sha256")))] | if length == 1 then .[0].name else empty end' /tmp/release.json)
if [ -z "$TAG" ] || [ -z "$ASSET_ID" ] || [ -z "$ASSET_NAME" ] || [ -z "$CHECKSUM_ID" ] || [ -z "$CHECKSUM_NAME" ]; then
echo "Baseline release must provide exactly one ZIP and one ZIP.sha256 asset"
exit 1
fi
{
echo "tag=$TAG"
echo "baseline_version=$BASELINE_VERSION"
echo "target_version=$TARGET_VERSION"
echo "asset_id=$ASSET_ID"
echo "asset_name=$ASSET_NAME"
echo "checksum_id=$CHECKSUM_ID"
echo "checksum_name=$CHECKSUM_NAME"
echo "skip=false"
} >> "$GITHUB_OUTPUT"
echo "Upgrade range: $BASELINE_VERSION → $TARGET_VERSION ($ASSET_NAME; checksum verified before use)"
# ── Step 2: Install released schema ───────────────────────────────────
- name: Install released schema (baseline)
id: baseline
if: steps.release.outputs.skip == 'false'
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
BASELINE_VERSION: ${{ steps.release.outputs.baseline_version }}
ASSET_ID: ${{ steps.release.outputs.asset_id }}
ARCHIVE_NAME: ${{ steps.release.outputs.asset_name }}
CHECKSUM_ID: ${{ steps.release.outputs.checksum_id }}
CHECKSUM_NAME: ${{ steps.release.outputs.checksum_name }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
run: |
RELEASE_DIR=$(mktemp -d "${RUNNER_TEMP}/pinakes-release.XXXXXX")
gh api -H "Accept: application/octet-stream" \
"repos/${GH_REPO}/releases/assets/${ASSET_ID}" \
> "${RELEASE_DIR}/${ARCHIVE_NAME}"
gh api -H "Accept: application/octet-stream" \
"repos/${GH_REPO}/releases/assets/${CHECKSUM_ID}" \
> "${RELEASE_DIR}/${CHECKSUM_NAME}"
(cd "$RELEASE_DIR" && sha256sum -c "$CHECKSUM_NAME")
# The pre-immutability v0.7.63 release can technically have both its
# ZIP and sidecar replaced. Its GitHub provenance attestation pins the
# archive digest and source repository independently of those assets.
gh attestation verify "${RELEASE_DIR}/${ARCHIVE_NAME}" --repo "$GH_REPO"
ARCHIVE_PATH="${RELEASE_DIR}/${ARCHIVE_NAME}" EXPECTED_ROOT="pinakes-${RELEASE_TAG}" python3 <<'PY'
import os
import stat
import zipfile
from pathlib import PurePosixPath
archive = os.environ['ARCHIVE_PATH']
expected_root = os.environ['EXPECTED_ROOT']
with zipfile.ZipFile(archive) as package:
entries = package.infolist()
if not entries:
raise SystemExit('Baseline release ZIP is empty')
for entry in entries:
name = entry.filename
path = PurePosixPath(name)
mode = (entry.external_attr >> 16) & 0o170000
if ('\\' in name or path.is_absolute() or '..' in path.parts
or not path.parts or path.parts[0] != expected_root
or mode == stat.S_IFLNK):
raise SystemExit(f'Unsafe baseline ZIP entry: {name!r}')
PY
mkdir "${RELEASE_DIR}/extracted"
unzip -q "${RELEASE_DIR}/${ARCHIVE_NAME}" -d "${RELEASE_DIR}/extracted"
BASELINE_APP="${RELEASE_DIR}/extracted/pinakes-${RELEASE_TAG}"
SCHEMA="${BASELINE_APP}/installer/database/schema.sql"
if [ ! -f "$SCHEMA" ] || [ ! -f "${BASELINE_APP}/vendor/autoload.php" ]; then
echo "Expected application root pinakes-${RELEASE_TAG} not found in release ZIP — aborting"
exit 1
fi
EMBEDDED_VERSION=$(jq -er '.version' "${BASELINE_APP}/version.json")
[ "$EMBEDDED_VERSION" = "$BASELINE_VERSION" ] || {
echo "Baseline tag/archive mismatch: $RELEASE_TAG contains version $EMBEDDED_VERSION"
exit 1
}
echo "release_dir=${BASELINE_APP}" >> "$GITHUB_OUTPUT"
echo "Using schema: $SCHEMA (from $RELEASE_TAG)"
mysql -h 127.0.0.1 -u root -proot pinakes_upgrade < "$SCHEMA"
# schema.sql intentionally contains structure only. A real installed
# baseline has already booted PluginManager, which creates the bundled
# registry rows and plugin-owned schemas. Reproduce that state with the
# code and manifests from the downloaded release, before any overlay.
BASELINE_APP="$BASELINE_APP" php <<'PHP'
<?php
declare(strict_types=1);
// The CLI SAPI does not define STDIN/STDOUT/STDERR when the script is
// read from standard input (as these heredoc scripts are) - define them.
if (!defined('STDOUT')) { define('STDOUT', fopen('php://stdout', 'wb')); }
if (!defined('STDERR')) { define('STDERR', fopen('php://stderr', 'wb')); }
$baseline = (string) getenv('BASELINE_APP');
require $baseline . '/vendor/autoload.php';
mysqli_report(MYSQLI_REPORT_OFF);
$db = new mysqli('127.0.0.1', 'root', 'root', 'pinakes_upgrade', 3306);
if ($db->connect_errno !== 0) {
fwrite(STDERR, "Database connection failed: {$db->connect_error}\n");
exit(1);
}
$db->set_charset('utf8mb4');
(new App\Support\PluginManager($db, new App\Support\HookManager($db)))
->autoRegisterBundledPlugins();
$expected = App\Support\BundledPlugins::LIST;
sort($expected, SORT_STRING);
$actual = [];
$result = $db->query('SELECT name FROM plugins ORDER BY name');
while ($result instanceof mysqli_result && ($row = $result->fetch_assoc()) !== null) {
$actual[] = (string) $row['name'];
}
if ($actual !== $expected) {
fwrite(STDERR, 'Baseline plugin registry mismatch: ' . json_encode($actual)
. ', expected ' . json_encode($expected) . "\n");
exit(1);
}
PHP
echo "✓ Base schema and bundled plugin registry installed from $RELEASE_TAG"
# ── Step 3: Fresh-install fallback (no prior release) ─────────────────
- name: Install base schema (fresh-install fallback)
if: steps.release.outputs.skip == 'true'
run: |
mysql -h 127.0.0.1 -u root -proot pinakes_upgrade < installer/database/schema.sql
php <<'PHP'
<?php
declare(strict_types=1);
// The CLI SAPI does not define STDIN/STDOUT/STDERR when the script is
// read from standard input (as these heredoc scripts are) - define them.
if (!defined('STDOUT')) { define('STDOUT', fopen('php://stdout', 'wb')); }
if (!defined('STDERR')) { define('STDERR', fopen('php://stderr', 'wb')); }
require getcwd() . '/vendor/autoload.php';
mysqli_report(MYSQLI_REPORT_OFF);
$db = new mysqli('127.0.0.1', 'root', 'root', 'pinakes_upgrade', 3306);
if ($db->connect_errno !== 0) {
fwrite(STDERR, "Database connection failed: {$db->connect_error}\n");
exit(1);
}
$db->set_charset('utf8mb4');
(new App\Support\PluginManager($db, new App\Support\HookManager($db)))
->autoRegisterBundledPlugins();
PHP
echo "✓ Base schema and bundled plugin registry installed from current branch"
# ── Step 4: Upgrade through the production migration runner ───────────
- name: Upgrade baseline through Updater and prove idempotency
if: steps.release.outputs.skip == 'false'
env:
BASELINE_APP: ${{ steps.baseline.outputs.release_dir }}
BASELINE_VERSION: ${{ steps.release.outputs.baseline_version }}
TARGET_VERSION: ${{ steps.release.outputs.target_version }}
run: |
PICKUP_COLUMNS_BEFORE=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \
"SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='pinakes_upgrade' AND table_name='prestiti' AND column_name IN ('pickup_notification_sent','pickup_notification_claim_token','pickup_notification_last_attempt_at')")
# The single-quoted payload below is PHP, not shell.
# shellcheck disable=SC2016
EXPECT_0764=$(BASELINE_VERSION="$BASELINE_VERSION" TARGET_VERSION="$TARGET_VERSION" php -r '
$from = (string) getenv("BASELINE_VERSION");
$to = (string) getenv("TARGET_VERSION");
echo version_compare($from, "0.7.64", "<") && version_compare($to, "0.7.64", ">=") ? "yes" : "no";
')
if [ "$EXPECT_0764" = "yes" ] && [ "$PICKUP_COLUMNS_BEFORE" -ne 0 ]; then
echo "Baseline $BASELINE_VERSION unexpectedly already has 0.7.64 pickup notification columns"
exit 1
fi
mysql -h 127.0.0.1 -u root -proot pinakes_upgrade <<'SQL'
DELETE FROM system_settings
WHERE category = 'migrations' AND setting_key = 'contributors_backfilled';
INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, tipo_utente, email_verificata)
VALUES ('UPGRADE-SMOKE-0764', 'Upgrade', 'Smoke', 'upgrade-smoke-0764@example.invalid', 'not-a-real-hash', 'attivo', 'standard', 1);
SET @upgrade_smoke_user_id = LAST_INSERT_ID();
INSERT INTO libri (titolo, illustratore, curatore)
VALUES (
'ZZ upgrade smoke contributor backfill',
'ZZ Upgrade Illustrator One; ZZ Upgrade Illustrator Two',
'García Márquez, Gabriel José'
);
SET @upgrade_smoke_book_id = LAST_INSERT_ID();
INSERT INTO prestiti (libro_id, utente_id, data_prestito, data_scadenza, stato, attivo)
VALUES (@upgrade_smoke_book_id, @upgrade_smoke_user_id, CURRENT_DATE, DATE_ADD(CURRENT_DATE, INTERVAL 7 DAY), 'da_ritirare', 1);
INSERT INTO prestiti (libro_id, utente_id, data_prestito, data_scadenza, data_restituzione, stato, attivo)
VALUES (@upgrade_smoke_book_id, @upgrade_smoke_user_id, DATE_SUB(CURRENT_DATE, INTERVAL 14 DAY), DATE_SUB(CURRENT_DATE, INTERVAL 7 DAY), DATE_SUB(CURRENT_DATE, INTERVAL 8 DAY), 'restituito', 0);
SQL
BASELINE_VERSION="$BASELINE_VERSION" TARGET_VERSION="$TARGET_VERSION" EXPECT_0764="$EXPECT_0764" php <<'PHP'
<?php
declare(strict_types=1);
// The CLI SAPI does not define STDIN/STDOUT/STDERR when the script is
// read from standard input (as these heredoc scripts are) - define them.
if (!defined('STDOUT')) { define('STDOUT', fopen('php://stdout', 'wb')); }
if (!defined('STDERR')) { define('STDERR', fopen('php://stderr', 'wb')); }
$baselineRoot = rtrim((string) getenv('BASELINE_APP'), DIRECTORY_SEPARATOR);
$currentRoot = (string) getcwd();
if ($baselineRoot === '' || !is_file($baselineRoot . '/vendor/autoload.php')) {
fwrite(STDERR, "Downloaded baseline application is missing\n");
exit(1);
}
require $baselineRoot . '/vendor/autoload.php';
mysqli_report(MYSQLI_REPORT_OFF);
$db = new mysqli('127.0.0.1', 'root', 'root', 'pinakes_upgrade', 3306);
if ($db->connect_errno !== 0) {
fwrite(STDERR, "Database connection failed: {$db->connect_error}\n");
exit(1);
}
$db->set_charset('utf8mb4');
$from = (string) getenv('BASELINE_VERSION');
$target = (string) getenv('TARGET_VERSION');
$baselineVersion = json_decode(
(string) file_get_contents($baselineRoot . '/version.json'),
true,
512,
JSON_THROW_ON_ERROR
)['version'] ?? '';
if ($baselineVersion !== $from) {
fwrite(STDERR, "Loaded baseline version {$baselineVersion}, expected {$from}\n");
exit(1);
}
// Force the starting release's Updater class and instantiate it BEFORE
// overlaying target files. installUpdate() behaves the same way: the old
// object remains in memory after it copies the new package, then that
// old runner executes the newly copied migration files.
$updaterReflection = new ReflectionClass(App\Support\Updater::class);
$loadedUpdaterFile = realpath((string) $updaterReflection->getFileName());
$expectedUpdaterFile = realpath($baselineRoot . '/app/Support/Updater.php');
if ($loadedUpdaterFile === false || $loadedUpdaterFile !== $expectedUpdaterFile) {
fwrite(STDERR, "Updater was not loaded from the downloaded baseline\n");
exit(1);
}
$updater = new App\Support\Updater($db);
/** @param non-empty-string $source @param non-empty-string $destination */
$copyTree = static function (string $source, string $destination): void {
if (!is_dir($destination) && !mkdir($destination, 0775, true) && !is_dir($destination)) {
throw new RuntimeException("Could not create overlay directory: {$destination}");
}
$prefixLength = strlen(rtrim($source, DIRECTORY_SEPARATOR)) + 1;
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($source, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $item) {
if ($item->isLink()) {
throw new RuntimeException("Target source overlay contains a symlink: {$item->getPathname()}");
}
$relative = substr($item->getPathname(), $prefixLength);
$targetPath = $destination . DIRECTORY_SEPARATOR . $relative;
if ($item->isDir()) {
if (!is_dir($targetPath) && !mkdir($targetPath, 0775, true) && !is_dir($targetPath)) {
throw new RuntimeException("Could not create overlay directory: {$targetPath}");
}
} elseif (!copy($item->getPathname(), $targetPath)) {
throw new RuntimeException("Could not overlay target file: {$relative}");
}
}
};
$copyTree($currentRoot . '/app', $baselineRoot . '/app');
$copyTree($currentRoot . '/storage/plugins', $baselineRoot . '/storage/plugins');
$copyTree(
$currentRoot . '/installer/database/migrations',
$baselineRoot . '/installer/database/migrations'
);
foreach (['installer/database/triggers.sql', 'version.json'] as $relativeFile) {
if (!copy($currentRoot . '/' . $relativeFile, $baselineRoot . '/' . $relativeFile)) {
throw new RuntimeException("Could not overlay target file: {$relativeFile}");
}
}
// Production synchronizes bundled manifests after copying their target
// files and before database migrations. The class was not loaded by the
// baseline Updater constructor, so autoload resolves this implementation
// from the newly overlaid target tree just as it does in installUpdate().
(new App\Support\PluginManager($db, new App\Support\HookManager($db)))
->autoRegisterBundledPlugins();
$shouldRun = static function (string $migration, string $rangeFrom, string $rangeTo): bool {
if (method_exists(App\Support\Updater::class, 'shouldRunMigration')) {
return App\Support\Updater::shouldRunMigration($migration, $rangeFrom, $rangeTo);
}
return version_compare($migration, $rangeFrom, '>')
&& version_compare($migration, $rangeTo, '<=');
};
foreach ([
['0.7.63', '0.7.64-rc.1', true],
['0.7.63', '0.7.64', true],
['0.7.63', '0.7.65-rc.1', true],
] as [$matrixFrom, $matrixTarget, $matrixExpected]) {
if ($shouldRun('0.7.64-rc.1', $matrixFrom, $matrixTarget) !== $matrixExpected) {
fwrite(STDERR, "Migration range predicate failed for {$matrixFrom} → {$matrixTarget}\n");
exit(1);
}
}
$expected = array_values(array_filter(
array_map('basename', glob($baselineRoot . '/installer/database/migrations/migrate_*.sql') ?: []),
static function (string $filename) use ($from, $target, $shouldRun): bool {
if (preg_match('/^migrate_(.+)\.sql$/', $filename, $matches) !== 1) {
return false;
}
return $shouldRun($matches[1], $from, $target);
}
));
usort($expected, static function (string $a, string $b): int {
preg_match('/^migrate_(.+)\.sql$/', $a, $aMatch);
preg_match('/^migrate_(.+)\.sql$/', $b, $bMatch);
return version_compare($aMatch[1], $bMatch[1]);
});
$first = $updater->runMigrations($from, $target);
if (!$first['success']) {
fwrite(STDERR, 'Updater failed: ' . ($first['error'] ?? 'unknown error') . "\n");
exit(1);
}
if ($first['executed'] !== $expected) {
fwrite(STDERR, 'Updater executed ' . json_encode($first['executed'])
. ', expected ' . json_encode($expected) . "\n");
exit(1);
}
if (getenv('EXPECT_0764') === 'yes' && !in_array('migrate_0.7.64-rc.1.sql', $first['executed'], true)) {
fwrite(STDERR, "Updater did not select migrate_0.7.64-rc.1.sql for the tested version range\n");
exit(1);
}
$second = $updater->runMigrations($from, $target);
if (!$second['success'] || $second['executed'] !== []) {
fwrite(STDERR, 'Second Updater pass was not an idempotent no-op: '
. json_encode($second) . "\n");
exit(1);
}
fwrite(STDOUT, "✓ Loaded baseline Updater from {$loadedUpdaterFile}, then overlaid target files\n");
PHP
if [ "$EXPECT_0764" = "yes" ]; then
COLUMN_META=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \
"SELECT CONCAT(DATA_TYPE, '|', IS_NULLABLE, '|', COALESCE(COLUMN_DEFAULT, 'NULL')) FROM information_schema.columns WHERE table_schema='pinakes_upgrade' AND table_name='prestiti' AND column_name='pickup_notification_sent'")
[ "$COLUMN_META" = "tinyint|YES|0" ] || { echo "Unexpected pickup_notification_sent definition: $COLUMN_META"; exit 1; }
TOKEN_META=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \
"SELECT CONCAT(DATA_TYPE, '|', CHARACTER_MAXIMUM_LENGTH, '|', IS_NULLABLE, '|', COALESCE(COLUMN_DEFAULT, 'NULL'), '|', COLLATION_NAME) FROM information_schema.columns WHERE table_schema='pinakes_upgrade' AND table_name='prestiti' AND column_name='pickup_notification_claim_token'")
[ "$TOKEN_META" = "char|32|YES|NULL|ascii_bin" ] || { echo "Unexpected pickup_notification_claim_token definition: $TOKEN_META"; exit 1; }
ATTEMPT_META=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \
"SELECT CONCAT(DATA_TYPE, '|', IS_NULLABLE, '|', COALESCE(COLUMN_DEFAULT, 'NULL')) FROM information_schema.columns WHERE table_schema='pinakes_upgrade' AND table_name='prestiti' AND column_name='pickup_notification_last_attempt_at'")
[ "$ATTEMPT_META" = "datetime|YES|NULL" ] || { echo "Unexpected pickup_notification_last_attempt_at definition: $ATTEMPT_META"; exit 1; }
READY_BACKFILL=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \
"SELECT pickup_notification_sent FROM prestiti p JOIN utenti u ON u.id=p.utente_id WHERE u.codice_tessera='UPGRADE-SMOKE-0764' AND p.stato='da_ritirare'")
[ "$READY_BACKFILL" = "1" ] || { echo "Historical ready-for-pickup loan was not marked as already notified"; exit 1; }
OTHER_BACKFILL=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \
"SELECT pickup_notification_sent FROM prestiti p JOIN utenti u ON u.id=p.utente_id WHERE u.codice_tessera='UPGRADE-SMOKE-0764' AND p.stato='restituito'")
[ "$OTHER_BACKFILL" = "1" ] || { echo "Historical non-ready loan did not receive the legacy-safe sent marker"; exit 1; }
NEW_DEFAULT=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e "
INSERT INTO prestiti (libro_id, utente_id, data_prestito, data_scadenza, data_restituzione, stato, attivo)
SELECT l.id, u.id, CURRENT_DATE, DATE_ADD(CURRENT_DATE, INTERVAL 7 DAY), CURRENT_DATE, 'restituito', 0
FROM libri l
JOIN utenti u ON u.codice_tessera='UPGRADE-SMOKE-0764'
WHERE l.titolo='ZZ upgrade smoke contributor backfill'
LIMIT 1;
SELECT CONCAT(pickup_notification_sent, '|', COALESCE(pickup_notification_claim_token, 'NULL'), '|', COALESCE(pickup_notification_last_attempt_at, 'NULL')) FROM prestiti WHERE id=LAST_INSERT_ID();")
[ "$NEW_DEFAULT" = "0|NULL|NULL" ] || { echo "New post-upgrade loan received unexpected pickup claim defaults: $NEW_DEFAULT"; exit 1; }
MIGRATION_RECORD=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \
"SELECT CONCAT(COUNT(*), '|', COALESCE(MAX(filename), '')) FROM migrations WHERE version='0.7.64-rc.1'")
[ "$MIGRATION_RECORD" = "1|migrate_0.7.64-rc.1.sql" ] || { echo "Unexpected 0.7.64 migration record: $MIGRATION_RECORD"; exit 1; }
echo "✓ migrate_0.7.64-rc.1.sql selected by baseline Updater; legacy marker/default/record verified"
fi
BOOK_ID=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \
"SELECT id FROM libri WHERE titolo='ZZ upgrade smoke contributor backfill' LIMIT 1")
[ -n "$BOOK_ID" ] || { echo "Backfill fixture book missing"; exit 1; }
MARKER=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \
"SELECT setting_value FROM system_settings WHERE category='migrations' AND setting_key='contributors_backfilled'")
ILLUSTRATORS=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \
"SELECT COUNT(*) FROM libri_autori WHERE libro_id=$BOOK_ID AND ruolo='illustratore'")
CURATORS=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \
"SELECT COUNT(*) FROM libri_autori WHERE libro_id=$BOOK_ID AND ruolo='curatore'")
CANONICAL_CURATOR=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \
"SELECT COUNT(*) FROM libri_autori la JOIN autori a ON a.id=la.autore_id WHERE la.libro_id=$BOOK_ID AND la.ruolo='curatore' AND a.nome='Gabriel José García Márquez'")
PROVENANCE=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \
"SELECT COUNT(*) FROM libri_autori_import_sources WHERE libro_id=$BOOK_ID AND source='legacy-backfill'")
[ "$MARKER" = "1" ] || { echo "Contributor backfill marker not set"; exit 1; }
[ "$ILLUSTRATORS" -eq 2 ] || { echo "Expected 2 illustrator links, got $ILLUSTRATORS"; exit 1; }
[ "$CURATORS" -eq 1 ] || { echo "Expected 1 curator link, got $CURATORS"; exit 1; }
[ "$CANONICAL_CURATOR" -eq 1 ] || { echo "SBN comma name was not preserved and normalized correctly"; exit 1; }
[ "$PROVENANCE" -eq 3 ] || { echo "Expected 3 provenance rows, got $PROVENANCE"; exit 1; }
echo "✓ Upgrade ran through Updater and the second pass was a no-op"
# ── Step 5: Verify schema after upgrade ──────────────────────────────
- name: Verify core tables present
run: |
TABLE_OUTPUT=$(php scripts/list-source-expectations.php tables)
mapfile -t CORE_TABLES <<< "$TABLE_OUTPUT"
[ "${#CORE_TABLES[@]}" -gt 0 ] || { echo "No tables derived from schema.sql"; exit 1; }
FAILED=0
MISSING=""
for t in "${CORE_TABLES[@]}"; do
EXISTS=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='pinakes_upgrade' AND table_name='$t'")
if [ "$EXISTS" -eq 0 ]; then
MISSING="$MISSING $t"
FAILED=1
fi
done
if [ "$FAILED" -eq 0 ]; then
echo "✓ All ${#CORE_TABLES[@]} schema.sql tables present"
else
echo "✗ Missing tables:$MISSING"
exit 1
fi
- name: Verify ENUM values
run: |
FAILED=0
V=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \
"SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS \
WHERE TABLE_SCHEMA='pinakes_upgrade' \
AND TABLE_NAME='oai_deleted_records' \
AND COLUMN_NAME='entity_type'" 2>/dev/null || echo "")
if [ -n "$V" ]; then
echo "oai_deleted_records.entity_type: $V"
if echo "$V" | grep -q "archival_unit"; then
echo " ✓ 'archival_unit' present"
else
echo " ✗ 'archival_unit' missing"
FAILED=1
fi
if echo "$V" | grep -q "'archive_unit'"; then
echo " ✗ old typo 'archive_unit' still present"
FAILED=1
fi
fi
V=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \
"SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS \
WHERE TABLE_SCHEMA='pinakes_upgrade' \
AND TABLE_NAME='prestiti' \
AND COLUMN_NAME='origine'" 2>/dev/null || echo "")
if [ -n "$V" ]; then
echo "prestiti.origine: $V"
if echo "$V" | grep -q "'ncip'"; then
echo " ✓ 'ncip' present"
else
echo " ✗ 'ncip' missing from prestiti.origine"
FAILED=1
fi
fi
[ "$FAILED" -eq 0 ] || exit 1
- name: Verify key columns from recent migrations
env:
TARGET_VERSION: ${{ steps.release.outputs.target_version }}
BASELINE_SKIPPED: ${{ steps.release.outputs.skip }}
run: |
FAILED=0
check_col() {
local TABLE="$1" COL="$2"
EXISTS=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \
"SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='pinakes_upgrade' AND table_name='$TABLE' AND column_name='$COL'")
if [ "$EXISTS" -eq 0 ]; then
echo " ✗ Missing: $TABLE.$COL"
FAILED=1
else
echo " ✓ $TABLE.$COL"
fi
}
# v0.7.0 — VIAF/ISNI
check_col autori viaf_id
check_col autori isni_id
# v0.7.3 — NCIP
check_col prestiti origine
# v0.7.4 — NCIP partner attrs
check_col ncip_partners isil
check_col ncip_partners notes
# v0.7.4 — digital_assets
check_col digital_assets libro_id
check_col digital_assets url
check_col digital_assets md5_hash
check_col digital_assets filesize
check_col digital_assets filetype
# v0.7.64 — pickup notification claim/retry state. A fresh-install
# schema always reflects the checkout; an upgrade only expects this
# stable migration when the semver range actually includes 0.7.64.
EXPECT_0764=$(TARGET_VERSION="$TARGET_VERSION" php -r '
echo version_compare((string) getenv("TARGET_VERSION"), "0.7.64", ">=") ? "yes" : "no";
')
if [ "$BASELINE_SKIPPED" = "true" ] || [ "$EXPECT_0764" = "yes" ]; then
check_col prestiti pickup_notification_sent
check_col prestiti pickup_notification_claim_token
check_col prestiti pickup_notification_last_attempt_at
else
echo " ↳ prestiti.pickup_notification_sent not expected before stable 0.7.64"
fi
[ "$FAILED" -eq 0 ] || exit 1
- name: Verify plugin registrations
run: |
PLUGIN_OUTPUT=$(php scripts/list-source-expectations.php plugins)
mapfile -t EXPECTED_PLUGINS <<< "$PLUGIN_OUTPUT"
[ "${#EXPECTED_PLUGINS[@]}" -gt 0 ] || { echo "No bundled plugins derived from BundledPlugins::LIST"; exit 1; }
FAILED=0
for plugin in "${EXPECTED_PLUGINS[@]}"; do
[[ "$plugin" =~ ^[a-z0-9-]+$ ]] || { echo "Invalid bundled plugin slug: $plugin"; exit 1; }
EXPECTED_VERSION=$(jq -er '.version | select(type == "string" and length > 0)' "storage/plugins/${plugin}/plugin.json")
ROW=$(mysql -h 127.0.0.1 -u root -proot pinakes_upgrade -sN -e \
"SELECT CONCAT(COUNT(*), '|', COALESCE(MAX(version), '')) FROM plugins WHERE name='$plugin'" 2>/dev/null || echo "0|")
if [ "$ROW" != "1|$EXPECTED_VERSION" ]; then
echo " ✗ Plugin registry mismatch: $plugin (got $ROW, expected 1|$EXPECTED_VERSION)"
FAILED=1
else
echo " ✓ $plugin $EXPECTED_VERSION"
fi
done
[ "$FAILED" -eq 0 ] || exit 1