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
25 changes: 19 additions & 6 deletions .github/workflows/hydra-gates-schema-sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,15 @@ 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.
# Daily 04:00 UTC. Weekly was too slow: the library publishes several
# schema versions a week, and between two Monday runs apps adopted keys
# the gate still refused (2.96.0 vendored against 2.138.0 published,
# 2026-10-21). Monday's run still lands an hour BEFORE
# fleet-shared-dep-bump.
#
# CRON IS UTC AND DOES NOT KNOW ABOUT SUMMER TIME. Nothing here needs a
# precise local hour.
- cron: "0 4 * * 1"
- cron: "0 4 * * *"
workflow_dispatch:
inputs:
dry_run:
Expand Down Expand Up @@ -187,9 +189,20 @@ jobs:
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}" \
NEW_PR=$(gh pr create --base main --head "${BRANCH}" \
--title "chore(hydra-gates): vendor manifest schema ${WANT}" \
--body-file pr-body.md
--body-file pr-body.md)
echo "${NEW_PR}"

# An older sync PR left open is how the copy stayed behind: each run
# opened a new branch and the previous one sat unmerged. Close the
# ones this PR supersedes, so exactly one sync PR is open at a time.
gh pr list --state open --base main --json number,headRefName \
--jq ".[] | select(.headRefName | startswith(\"chore/vendored-manifest-schema-\")) | select(.headRefName != \"${BRANCH}\") | .number" \
| while read -r OLD; do
gh pr close "${OLD}" --delete-branch \
--comment "Superseded by ${NEW_PR}, which vendors ${WANT}."
done

- name: Report
if: always()
Expand Down
12 changes: 11 additions & 1 deletion hydra-gates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,17 @@ 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
day and opens a pull request when the copy falls behind, closing any older sync
pull request it supersedes. Weekly was not enough: on 2026-10-21 the copy was at
2.96.0 against a published 2.138.0, with two sync pull requests sitting open,
and dossiq, portaliq and shillinq failed gates 22 and 53 on keys the library
already accepted.

Drift is also named where it bites. When the app running the gate has a newer
`@conduction/nextcloud-vue` installed than the vendored copy, gates 22 and 53
append `VENDORED SCHEMA BEHIND: …` to their failure reason. To check by hand,
`node scripts/lib/vendored_schema_freshness.js` compares the copy with npm
latest (`--strict` exits 1 when 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
Expand Down
8 changes: 8 additions & 0 deletions hydra-gates/scripts/lib/check_manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const MANIFEST_PATH = _positional[0]
: path.resolve(process.cwd(), 'src', 'manifest.json')

const scopeFilter = require('./manifest_scope_filter.js')
const freshness = require('./vendored_schema_freshness.js')

// Base-schema candidates, in priority order. The CANONICAL hydra-vendored
// schema wins (2026-07-06 manifest audit, item 4): pinned-first meant "pass"
Expand Down Expand Up @@ -503,6 +504,13 @@ function main() {
const basePath = findFirst(BASE_SCHEMA_CANDIDATES)
const base = basePath ? loadJson(basePath) : canonical
console.error(`[check_manifest] base schema: ${basePath || CANONICAL_SCHEMA_PATH}`)
const installed = freshness.installedSchemaPath(process.cwd())
const behind = installed && freshness.behindNote(
canonical.version,
freshness.schemaVersion(installed),
'the @conduction/nextcloud-vue installed in this app',
)
if (behind) console.error(`[check_manifest] ${behind}`)
console.error('[check_manifest] AppHost blocks (observability, deepLinks) validated against the hydra-vendored canonical ADR-040 schema')
const schema = mergeAppHostBlocks(base, canonical)

Expand Down
12 changes: 8 additions & 4 deletions hydra-gates/scripts/lib/test_gate_45_to_55_acceptance.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1488,8 +1488,10 @@ _outK2="${_tmp}/k2.txt"
_run "${_appK2}" "${_outK2}"
_expect "${_outK2}" 53 "FAIL" "still rejects an unknown action key and an out-of-enum size"
_logK2="$(sed -n 's/.*see \(.*hydra-gate-effective-manifest-crossref\.log\).*/\1/p' "${_outK2}" | head -1)"
if [ -n "${_logK2}" ] && grep -q 'additionalProperties' "${_logK2}" && grep -q 'enum' "${_logK2}"; then
_ok "gate-53 names BOTH planted defects (additionalProperties + enum)"
# Schema 2.138.0 closes the action with unevaluatedProperties instead of
# additionalProperties; either keyword is the unknown-key refusal.
if [ -n "${_logK2}" ] && grep -qE '(additional|unevaluated)Properties' "${_logK2}" && grep -q 'enum' "${_logK2}"; then
_ok "gate-53 names BOTH planted defects (closed object + enum)"
else
_bad "gate-53 failed without naming both planted defects — a schema loosened one way would pass this"
fi
Expand Down Expand Up @@ -1633,8 +1635,10 @@ _outM2="${_tmp}/m2.txt"
_run "${_appM2}" "${_outM2}"
_expect "${_outM2}" 22 "FAIL" "still rejects an unknown store key and an out-of-enum page type"
_logM2="$(sed -n 's/.*see \(.*hydra-gate-manifest-validation\.log\).*/\1/p' "${_outM2}" | head -1)"
if [ -n "${_logM2}" ] && grep -q 'additionalProperties' "${_logM2}" && grep -q 'enum' "${_logM2}"; then
_ok "gate-22 names BOTH planted defects (additionalProperties + enum)"
# Schema 2.138.0 closes the action with unevaluatedProperties instead of
# additionalProperties; either keyword is the unknown-key refusal.
if [ -n "${_logM2}" ] && grep -qE '(additional|unevaluated)Properties' "${_logM2}" && grep -q 'enum' "${_logM2}"; then
_ok "gate-22 names BOTH planted defects (closed object + enum)"
else
_bad "gate-22 failed without naming both planted defects — a schema loosened one way would pass this"
fi
Expand Down
84 changes: 84 additions & 0 deletions hydra-gates/scripts/lib/test_vendored_schema_freshness.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/usr/bin/env node
// SPDX-License-Identifier: EUPL-1.2
//
// test_vendored_schema_freshness.js: the drift between the vendored manifest
// schema and the one @conduction/nextcloud-vue ships is named, never silent.
// Offline: every comparison runs against schema files written here.
//
// Run: node scripts/lib/test_vendored_schema_freshness.js

'use strict'

const fs = require('fs')
const os = require('os')
const path = require('path')
const { spawnSync } = require('child_process')
const f = require('./vendored_schema_freshness.js')

let fails = 0
function check(name, cond) {
console.log(` ${cond ? 'PASS' : 'FAIL'}: ${name}`)
if (!cond) fails++
}

const work = fs.mkdtempSync(path.join(os.tmpdir(), 'hg-fresh-'))
const vendored = f.schemaVersion(f.VENDORED_PATH)
function schemaFile(name, version) {
const p = path.join(work, name)
fs.writeFileSync(p, JSON.stringify({ version }))
return p
}
function run(args, cwd) {
return spawnSync(process.execPath, [path.join(__dirname, 'vendored_schema_freshness.js'), ...args], { encoding: 'utf8', cwd })
}

try {
console.log('== compareVersions ==')
check('2.138.0 > 2.96.0 (numeric, not lexical)', f.compareVersions('2.138.0', '2.96.0') > 0)
check('2.96.0 < 2.138.0', f.compareVersions('2.96.0', '2.138.0') < 0)
check('equal versions compare 0', f.compareVersions('2.96.0', '2.96.0') === 0)
check('pre-release suffix ignored', f.compareVersions('3.0.0-beta.4', '3.0.0') === 0)

console.log('== behindNote ==')
check('newer source names the drift', /VENDORED SCHEMA BEHIND/.test(f.behindNote('2.96.0', '2.138.0', 'x') || ''))
check('same version is silent', f.behindNote('2.96.0', '2.96.0', 'x') === null)
check('older source is silent', f.behindNote('2.138.0', '2.96.0', 'x') === null)
check('missing version is silent, not a false alarm', f.behindNote('2.96.0', null, 'x') === null)

console.log('== the vendored copy declares a version ==')
check(`vendored schema has a version (${vendored})`, typeof vendored === 'string')

console.log('== CLI ==')
const newer = schemaFile('newer.json', '999.0.0')
let r = run(['--published', newer])
check('behind: WARN and exit 0 by default', r.status === 0 && /^WARN VENDORED SCHEMA BEHIND/.test(r.stdout))
r = run(['--published', newer, '--strict'])
check('behind with --strict: FAIL and exit 1', r.status === 1 && /^FAIL VENDORED SCHEMA BEHIND/.test(r.stdout))
r = run(['--published', schemaFile('same.json', vendored), '--strict'])
check('level: OK and exit 0 under --strict', r.status === 0 && /^OK /.test(r.stdout))
r = run(['--published', schemaFile('nover.json', '')])
check('unreadable version: exit 2, not a pass', r.status === 2)

const app = path.join(work, 'app')
const lib = path.join(app, 'node_modules', '@conduction', 'nextcloud-vue', 'src', 'schemas')
fs.mkdirSync(lib, { recursive: true })
fs.writeFileSync(path.join(lib, 'app-manifest-v2.schema.json'), JSON.stringify({ version: '999.0.0' }))
r = run(['--app-dir', app])
check('--app-dir reads the installed library schema', r.status === 0 && /installed under/.test(r.stdout))
r = run(['--app-dir', path.join(work, 'no-such-app')])
check('--app-dir without the library: exit 2', r.status === 2)

console.log('== check_manifest.js names the drift in a gate log ==')
fs.mkdirSync(path.join(app, 'src'), { recursive: true })
fs.writeFileSync(path.join(app, 'src', 'manifest.json'), '{}')
r = spawnSync(process.execPath, [path.join(__dirname, 'check_manifest.js'), 'src/manifest.json'], { encoding: 'utf8', cwd: app })
check('installed library newer than vendored copy: note on stderr', /VENDORED SCHEMA BEHIND/.test(r.stderr))
fs.writeFileSync(path.join(lib, 'app-manifest-v2.schema.json'), JSON.stringify({ version: vendored }))
r = spawnSync(process.execPath, [path.join(__dirname, 'check_manifest.js'), 'src/manifest.json'], { encoding: 'utf8', cwd: app })
check('installed library level with vendored copy: no note', !/VENDORED SCHEMA BEHIND/.test(r.stderr))
} finally {
fs.rmSync(work, { recursive: true, force: true })
}

console.log(fails === 0 ? '\n== all passed ==' : `\n== ${fails} failed ==`)
process.exit(fails === 0 ? 0 : 1)
145 changes: 145 additions & 0 deletions hydra-gates/scripts/lib/vendored_schema_freshness.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
#!/usr/bin/env node
// SPDX-License-Identifier: EUPL-1.2
//
// vendored_schema_freshness.js: is scripts/schemas/app-manifest-v2.schema.json
// behind the schema @conduction/nextcloud-vue ships?
//
// Gates 22 and 53 validate against the vendored copy. When it falls behind,
// a manifest that uses a key the library already accepts is rejected by the
// gate while the app's own `npm run check:manifest` passes. The weekly sync
// workflow opens a pull request for that, but an unmerged sync PR left the
// copy 42 schema versions behind (2.96.0 against 2.138.0) and the gate's
// findings never said why. This helper names the drift.
//
// Two sources to compare against:
// - offline: the schema in an app's installed node_modules (used by
// check_manifest.js, so a gate log says when the app's library is newer);
// - online: the latest published package on npm (the CLI default).
//
// CLI:
// node vendored_schema_freshness.js compare against npm latest
// node vendored_schema_freshness.js --published F compare against schema file F
// node vendored_schema_freshness.js --app-dir D compare against D/node_modules
// --strict exit 1 when the vendored copy is behind (default: warn, exit 0)
//
// Exit: 0 current (or behind without --strict), 1 behind with --strict,
// 2 the comparison could not be made.

'use strict'

const fs = require('fs')
const os = require('os')
const path = require('path')
const { execFileSync } = require('child_process')

const PACKAGE = '@conduction/nextcloud-vue'
const SCHEMA_IN_PACKAGE = path.join('src', 'schemas', 'app-manifest-v2.schema.json')
const VENDORED_PATH = path.resolve(__dirname, '..', 'schemas', 'app-manifest-v2.schema.json')

// Numeric x.y.z comparison; a pre-release suffix is ignored. Returns <0, 0, >0.
function compareVersions(a, b) {
const pa = String(a).split('-')[0].split('.').map((n) => parseInt(n, 10) || 0)
const pb = String(b).split('-')[0].split('.').map((n) => parseInt(n, 10) || 0)
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] || 0) - (pb[i] || 0)
if (d !== 0) return d
}
return 0
}

function schemaVersion(file) {
try {
const v = JSON.parse(fs.readFileSync(file, 'utf8')).version
return typeof v === 'string' && v !== '' ? v : null
} catch (_) {
return null
}
}

// The schema shipped by the library installed under appDir, or null.
function installedSchemaPath(appDir) {
const p = path.join(appDir, 'node_modules', '@conduction', 'nextcloud-vue', SCHEMA_IN_PACKAGE)
return fs.existsSync(p) ? p : null
}

// One line naming the drift when `otherVersion` is newer than the vendored
// copy, otherwise null.
function behindNote(vendored, other, source) {
if (!vendored || !other || compareVersions(other, vendored) <= 0) return null
return `VENDORED SCHEMA BEHIND: gates 22/53 validate against vendored app-manifest-v2 ${vendored}, `
+ `but ${source} ships ${other}. A key added since ${vendored} is rejected here while the library accepts it; `
+ 're-vendor hydra-gates/scripts/schemas/app-manifest-v2.schema.json (workflow hydra-gates-schema-sync).'
}

// Downloads the latest published package into a temp dir and returns
// { lib, file }. Throws when npm cannot answer.
function fetchPublished() {
const lib = execFileSync('npm', ['view', PACKAGE, 'version'], { encoding: 'utf8', timeout: 60000 }).trim()
if (!lib) throw new Error(`npm view ${PACKAGE} version returned nothing`)
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hg-schema-'))
const tgz = execFileSync('npm', ['pack', `${PACKAGE}@${lib}`, '--silent'], { cwd: dir, encoding: 'utf8', timeout: 120000 }).trim().split('\n').pop()
execFileSync('tar', ['xzf', tgz, 'package/' + SCHEMA_IN_PACKAGE.split(path.sep).join('/')], { cwd: dir, timeout: 60000 })
return { lib, file: path.join(dir, 'package', SCHEMA_IN_PACKAGE) }
}

function main(argv) {
let published = null
let appDir = null
let strict = false
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--published') published = argv[++i]
else if (argv[i] === '--app-dir') appDir = argv[++i]
else if (argv[i] === '--strict') strict = true
else {
console.error(`unknown argument: ${argv[i]}`)
return 2
}
}

const vendored = schemaVersion(VENDORED_PATH)
if (!vendored) {
console.error(`[schema-freshness] cannot read a version from ${VENDORED_PATH}`)
return 2
}

let source
if (published) {
source = published
} else if (appDir) {
published = installedSchemaPath(appDir)
if (!published) {
console.error(`[schema-freshness] no ${PACKAGE} installed under ${appDir}`)
return 2
}
source = `${PACKAGE} installed under ${appDir}`
} else {
try {
const got = fetchPublished()
published = got.file
source = `${PACKAGE} ${got.lib} (npm latest)`
} catch (e) {
console.error(`[schema-freshness] could not fetch ${PACKAGE} from npm: ${e.message.split('\n')[0]}`)
return 2
}
}

const other = schemaVersion(published)
if (!other) {
console.error(`[schema-freshness] cannot read a version from ${published}`)
return 2
}

const note = behindNote(vendored, other, source)
if (note) {
console.log(`${strict ? 'FAIL' : 'WARN'} ${note}`)
return strict ? 1 : 0
}
console.log(`OK vendored app-manifest-v2 ${vendored} is level with or ahead of ${source} (${other})`)
return 0
}

if (require.main === module) {
process.exit(main(process.argv.slice(2)))
}

module.exports = { compareVersions, schemaVersion, installedSchemaPath, behindNote, VENDORED_PATH }
6 changes: 4 additions & 2 deletions hydra-gates/scripts/run-hydra-gates.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5102,7 +5102,8 @@ if [ -f src/manifest.json ]; then
_mv_why=$(head -3 "${_mv_log}" 2>/dev/null | tr '\n' ' ' | cut -c1-200)
_skip 22 "manifest-validation" wiring "the vendored canonical validator (${_mv_validator}) exited ${_mv_rc} WITHOUT reporting a single \`at <pointer>: <message>\` diagnostic, so src/manifest.json was NOT schema-validated and NO violation was measured. That is a crashed validator, not a finding about the manifest — the manifest is UNVERIFIED by this run. Validator output: ${_mv_why:-<empty>}. See ${_mv_log}${_mv_app_note}"
else
_fail 22 "manifest-validation" "${_mv_fail} schema violation(s) in src/manifest.json — see ${_mv_log}${_mv_app_note}"
_mv_stale=$(grep -m1 -o 'VENDORED SCHEMA BEHIND:.*' "${_mv_log}" 2>/dev/null || true)
_fail 22 "manifest-validation" "${_mv_fail} schema violation(s) in src/manifest.json — see ${_mv_log}${_mv_app_note}${_mv_stale:+ [${_mv_stale}]}"
fi ;;
esac
fi
Expand Down Expand Up @@ -9920,7 +9921,8 @@ if [ -f src/manifest.json ]; then
# by the prefix, so the count is what this PR must actually fix.
_em_n=$(grep -E '^at ' "${_em_log}" 2>/dev/null | grep -cvE ': (WARN|PRE-EXISTING) ' || true)
{ [ -z "${_em_n}" ] || [ "${_em_n}" -eq 0 ]; } && _em_n=1
_em_reason="${_em_n} structural violation(s) in the ASSEMBLED manifest (base+fragments+menu-layout) — see ${_em_log}"
_em_stale=$(grep -m1 -o 'VENDORED SCHEMA BEHIND:.*' "${_em_log}" 2>/dev/null || true)
_em_reason="${_em_n} structural violation(s) in the ASSEMBLED manifest (base+fragments+menu-layout) — see ${_em_log}${_em_stale:+ [${_em_stale}]}"
else
set +e
node "${_em_crossref}" --app-dir . --manifest "${_em_tmp}" ${_em_scope_flag} ${_em_scope_val} >> "${_em_log}" 2>&1
Expand Down
Loading
Loading