Skip to content
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
203 changes: 203 additions & 0 deletions .github/workflows/hydra-gates-schema-sync.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
# Vendored manifest schema sync: keep hydra-gates/scripts/schemas/
# app-manifest-v2.schema.json level with the copy @conduction/nextcloud-vue
# publishes, and open a pull request the moment it is not.
#
# ══════════════════════════════════════════════════════════════════════════
# WHY THIS EXISTS: A VENDORED COPY WITH NO UPDATE PATH DRIFTS BY DEFAULT
# ══════════════════════════════════════════════════════════════════════════
#
# Gates 22 and 53 validate an app's manifest against the schema vendored HERE,
# on purpose: the fleet's pinned @conduction/nextcloud-vue generations span a
# wide range, and pinned-first meant "valid against whatever the app happened
# to install". check_manifest.js says so in its own header.
#
# What was never decided is how the vendored copy CATCHES UP. Nothing watched
# the registry, so the answer was "when somebody notices", and measured
# 2026-09-19 nobody had for four minor versions:
#
# vendored 2.33.0
# @conduction/nextcloud-vue 3.4.0 ships 2.37.0
#
# The cost is not abstract. dossiq adopted `savedViewPlaces`, a key the library
# published and validates, `npm run check:manifest` passed with zero errors,
# and gates 22 and 53 rejected the same file. Two instruments, opposite
# verdicts, and `quality / Hydra Gates` is a required check on `development`,
# so the app could not merge on a manifest that was correct. Filed as #785.
#
# An app cannot fix this. The schema is not in its repo. So the fix has to
# live where the copy lives, which is here.
#
# ══════════════════════════════════════════════════════════════════════════
# WHY IT OPENS A PULL REQUEST AND DOES NOT PUSH TO main
# ══════════════════════════════════════════════════════════════════════════
#
# Because CI resolves these gates at `@main`. A push here reaches all 21 swept
# apps the same minute, and a schema bump is only SAFE when it is additive.
# Four minors were additive; the fifth need not be. A tightened `required`, a
# narrowed enum or a new closed property would redden manifests that pass
# today, and the first anyone would learn of it is a fleet of red PRs.
#
# So this workflow reports what moved and hands the judgement to a reviewer,
# who can run the additive check the issue describes: diff the two schemas for
# any constraint that got STRICTER, and validate every swept app's effective
# manifest against both before merging.
#
# It never fails a run over drift. Drift is a fact about the registry, not a
# defect in the pull request that happened to trigger the check, and a red
# leg nobody caused is a red leg nobody reads.
# ══════════════════════════════════════════════════════════════════════════

name: Vendored manifest schema sync

on:
schedule:
# Mondays 04:00 UTC, an hour BEFORE fleet-shared-dep-bump. An app that is
# about to be moved onto a newer nextcloud-vue should find the gate's
# schema already able to read what that release added.
#
# CRON IS UTC AND DOES NOT KNOW ABOUT SUMMER TIME. Nothing here needs a
# precise local hour.
- cron: "0 4 * * 1"
workflow_dispatch:
inputs:
dry_run:
description: "Report what would move, but open no pull request."
type: boolean
default: false

permissions:
contents: write
pull-requests: write

concurrency:
group: hydra-gates-schema-sync
cancel-in-progress: false

jobs:
sync:
name: "Compare vendored schema against the published one"
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: "24"

- name: Resolve both versions and diff
id: diff
env:
VENDORED: hydra-gates/scripts/schemas/app-manifest-v2.schema.json
run: |
set -euo pipefail

if [ ! -f "${VENDORED}" ]; then
echo "::error::${VENDORED} is missing. There is nothing to keep in step, which is a bigger problem than drift."
exit 1
fi

LIB=$(npm view @conduction/nextcloud-vue version)
if [ -z "${LIB}" ]; then
echo "::error::Could not resolve @conduction/nextcloud-vue from the registry. Refusing to compare against a blank: every comparison below would read 'already current'."
exit 1
fi

npm pack "@conduction/nextcloud-vue@${LIB}" >/dev/null
tar xzf "conduction-nextcloud-vue-${LIB}.tgz"
PUBLISHED=package/src/schemas/app-manifest-v2.schema.json
if [ ! -f "${PUBLISHED}" ]; then
echo "::error::@conduction/nextcloud-vue ${LIB} does not ship src/schemas/app-manifest-v2.schema.json. The vendored copy has no source to follow any more, so this workflow is the thing that needs changing."
exit 1
fi

HAVE=$(node -p "require('./${VENDORED}').version || 'unset'")
WANT=$(node -p "require('./${PUBLISHED}').version || 'unset'")
echo "vendored=${HAVE} published=${WANT} (from @conduction/nextcloud-vue ${LIB})"

{
echo "have=${HAVE}"
echo "want=${WANT}"
echo "lib=${LIB}"
} >> "$GITHUB_OUTPUT"

if cmp -s "${VENDORED}" "${PUBLISHED}"; then
echo "drift=no" >> "$GITHUB_OUTPUT"
echo "The vendored schema is byte-identical to the published one at ${WANT}." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi

echo "drift=yes" >> "$GITHUB_OUTPUT"
cp "${VENDORED}" "${PUBLISHED}.was-vendored"
cp "${PUBLISHED}" "${VENDORED}"

# Name every constraint that got STRICTER. An additive diff cannot
# redden a manifest that passes today; a tightened one can, and the
# reviewer needs to know which of the two this is before merging to
# a branch the whole fleet resolves at @main. The differ exits 1 when
# it finds one, which must NOT end this run: reporting the tightening
# is the whole point, and the pull request is where it gets read.
node hydra-gates/scripts/lib/diff_schema_strictness.js \
"${PUBLISHED}.was-vendored" "${VENDORED}" > tightened.txt || true
cat tightened.txt

- name: Open the pull request
if: steps.diff.outputs.drift == 'yes' && inputs.dry_run != true
env:
GH_TOKEN: ${{ github.token }}
HAVE: ${{ steps.diff.outputs.have }}
WANT: ${{ steps.diff.outputs.want }}
LIB: ${{ steps.diff.outputs.lib }}
run: |
set -euo pipefail
BRANCH="chore/vendored-manifest-schema-${WANT}"

# `set -e` plus a grep that finds nothing would end the run here and
# report a green "nothing to do", so the count is read into a
# variable and compared, not piped into a test.
OPEN=$(gh pr list --head "${BRANCH}" --state open --json number --jq 'length')
if [ "${OPEN}" != "0" ]; then
echo "A pull request for ${BRANCH} is already open. Leaving it alone."
exit 0
fi

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -b "${BRANCH}"
git add hydra-gates/scripts/schemas/app-manifest-v2.schema.json
git commit -m "chore(hydra-gates): vendor manifest schema ${WANT}" \
-- hydra-gates/scripts/schemas/app-manifest-v2.schema.json
git push -u origin "${BRANCH}"

{
echo "The vendored manifest schema was at ${HAVE}. @conduction/nextcloud-vue ${LIB} ships ${WANT}."
echo
echo "Gates 22 and 53 validate every app's manifest against the vendored copy, so until this merges an app that adopts a key ${WANT} added is rejected by the gate while its own \`npm run check:manifest\` passes."
echo
echo "## Before merging"
echo
echo "CI resolves these gates at \`@main\`, so this reaches all 21 swept apps the minute it lands. Constraints that got stricter in this diff:"
echo
echo '```'
cat tightened.txt
echo '```'
echo
echo "\`none\` means the diff is additive and cannot redden a manifest that passes today. Anything else means it can, and the fleet's effective manifests need validating against both schemas first."
echo
echo "Opened by .github/workflows/hydra-gates-schema-sync.yml. See #785 for why the vendored copy needs a keeper."
} > pr-body.md

gh pr create --base main --head "${BRANCH}" \
--title "chore(hydra-gates): vendor manifest schema ${WANT}" \
--body-file pr-body.md

- name: Report
if: always()
env:
DRIFT: ${{ steps.diff.outputs.drift }}
HAVE: ${{ steps.diff.outputs.have }}
WANT: ${{ steps.diff.outputs.want }}
run: |
if [ "${DRIFT:-}" = "yes" ]; then
echo "::notice::Vendored manifest schema ${HAVE} is behind the published ${WANT}."
fi
24 changes: 24 additions & 0 deletions hydra-gates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,30 @@ vendored schemas (`scripts/schemas/`) and the distributable entry point
(`bin/hydra-gates`). `ConductionNL/hydra` no longer carries a copy — it
delegates here (see [Why it lives in `.github`](#why-it-lives-in-github)).

### The vendored manifest schema, and who keeps it current

`scripts/schemas/app-manifest-v2.schema.json` is a **copy** of the schema
`@conduction/nextcloud-vue` publishes. Gates 22 and 53 judge every app's
manifest against this copy on purpose: the fleet's pinned library generations
differ, and pinned-first would mean "valid against whatever the app happened to
install".

A copy with no keeper drifts. Measured 2026-09-19 it was four minor versions
behind (2.33.0 against a published 2.37.0), and the two instruments gave
opposite verdicts on the same file: dossiq's `npm run check:manifest` passed
with zero errors while gates 22 and 53 rejected `savedViewPlaces`, a key the
library had published. No app could fix that, because the schema is not in any
app's repo (#785).

`.github/workflows/hydra-gates-schema-sync.yml` now watches the registry every
Monday and opens a pull request when the copy falls behind. It opens a pull
request rather than pushing, because CI resolves these gates at `@main`: a
bump reaches all 21 swept apps the minute it lands. The pull request carries
the output of `scripts/lib/diff_schema_strictness.js`, which names every
constraint that got **stricter**. `none` means the bump can only turn red into
green. Anything else means it can redden a manifest that passes today, and the
fleet's effective manifests need validating against both schemas first.

---

## Adopting it in a repo
Expand Down
130 changes: 130 additions & 0 deletions hydra-gates/scripts/lib/diff_schema_strictness.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env node
// SPDX-License-Identifier: EUPL-1.2
//
// diff_schema_strictness.js — name every constraint that got STRICTER between
// two JSON Schema documents.
//
// WHY THIS EXISTS. The manifest schema under scripts/schemas/ is a VENDORED
// copy of the one @conduction/nextcloud-vue publishes, and gates 22 and 53
// judge every app's manifest against it. CI resolves those gates at `@main`,
// so replacing the file reaches all 21 swept apps the minute it merges.
//
// That is safe when the newer schema only ADDS: a manifest valid under the old
// one stays valid, and the bump can only turn red into green. It is not safe
// when the newer schema tightens, because then a manifest that passes today
// starts failing and nothing in the app changed. The two cases look identical
// in a line diff of a three-thousand line schema, which is why this reads the
// structure instead.
//
// What counts as stricter:
// - a `required` list that gained an entry
// - an `enum` that lost a member
// - `additionalProperties` flipped from true to false
// - a new pattern / minLength / minItems / minimum / maximum / maxLength /
// maxItems / const where there was none
// - a declared property that DISAPPEARED from a `properties` block whose
// sibling `additionalProperties` is false. A removed property is not a
// relaxation there: the key it used to name becomes an unknown property
// and the object is refused. This is the case a line diff reads as
// "fewer rules" and it is the one that reddens a whole fleet.
//
// A key that is ABSENT from the old schema entirely is not reported: it cannot
// constrain a manifest the old schema already rejected as an unknown property.
//
// Usage: node diff_schema_strictness.js OLD.json NEW.json
//
// Exit codes:
// 0 — the diff is additive: nothing got stricter
// 1 — at least one constraint got stricter (each is printed, one per line)
// 2 — an argument is missing or is not parseable JSON

'use strict'

const fs = require('fs')

const [oldPath, newPath] = process.argv.slice(2)
if (!oldPath || !newPath) {
console.error('usage: diff_schema_strictness.js OLD.json NEW.json')
process.exit(2)
}

function load(p) {
try {
return JSON.parse(fs.readFileSync(p, 'utf8'))
} catch (e) {
console.error(`cannot read ${p}: ${e.message}`)
process.exit(2)
}
}

const NEW_CONSTRAINT_KEYS = [
'pattern', 'minLength', 'maxLength', 'minItems', 'maxItems',
'minimum', 'maximum', 'const', 'uniqueItems',
]

const findings = []

function walk(a, b, path) {
if (a === null || b === null) return
if (typeof a !== 'object' || typeof b !== 'object') return
if (Array.isArray(a) !== Array.isArray(b)) return

if (Array.isArray(a)) {
// Positional. A reordered schema keyword list would read as a change
// here; that is a false positive worth having over missing a real one.
for (let i = 0; i < Math.min(a.length, b.length); i++) {
walk(a[i], b[i], `${path}[${i}]`)
}
return
}

// A `properties` block under `additionalProperties: false` refuses every
// key it does not name, so dropping an entry from it is a TIGHTENING even
// though the file got shorter.
if (b.additionalProperties === false && a.properties && b.properties
&& typeof a.properties === 'object' && typeof b.properties === 'object') {
for (const gone of Object.keys(a.properties)) {
if (!Object.prototype.hasOwnProperty.call(b.properties, gone)) {
findings.push(`${path}/properties/${gone}: property removed while additionalProperties is false, so the key is now refused`)
}
}
}

for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
const here = `${path}/${key}`
const inA = Object.prototype.hasOwnProperty.call(a, key)
const inB = Object.prototype.hasOwnProperty.call(b, key)

if (key === 'required' && Array.isArray(b[key])) {
const before = Array.isArray(a[key]) ? a[key] : []
const gained = b[key].filter((v) => !before.includes(v))
if (gained.length) findings.push(`${here}: newly required ${JSON.stringify(gained)}`)
}

if (key === 'enum' && Array.isArray(a[key]) && Array.isArray(b[key])) {
const after = b[key].map((v) => JSON.stringify(v))
const lost = a[key].filter((v) => !after.includes(JSON.stringify(v)))
if (lost.length) findings.push(`${here}: enum no longer allows ${JSON.stringify(lost)}`)
}

if (key === 'additionalProperties' && a[key] === true && b[key] === false) {
findings.push(`${here}: additionalProperties true -> false`)
}

if (!inA && inB && NEW_CONSTRAINT_KEYS.includes(key)) {
findings.push(`${here}: new ${key} constraint ${JSON.stringify(b[key])}`)
}

if (inA && inB) walk(a[key], b[key], here)
}
}

walk(load(oldPath), load(newPath), '')

if (findings.length === 0) {
console.log('none')
process.exit(0)
}

for (const f of findings.sort()) console.log(f)
process.exit(1)
Loading
Loading