CI/CD and Code Standards
We build Nextcloud apps, so we hold ourselves to Nextcloud's rules:
Conduction code must pass Nextcloud's own checks unchanged. We may be stricter than Nextcloud; we may not be different from it.
"Stricter" means adding a rule Nextcloud has no opinion on. It never means giving one of their rules a different value — code that satisfies us must still satisfy them. Where a difference exists today it is a defect, not a dialect, and this page is where each one is tracked.
Two companion pages stay authoritative for what they cover: Development Pipeline for the branch flow and Release Process for versioning.
One pipeline, eighteen thin callers
Every core app's .github/workflows/code-quality.yml is a caller. The pipeline
itself is one reusable workflow:
jobs:
quality:
uses: ConductionNL/.github/.github/workflows/quality.yml@main
with:
app-name: myapp
# …feature flags…
Consumed at @main, deliberately. A pinned ref is a silent expiry date: 22 repos
once sat on gate package v1.0.1 while 16 gates were dead fleet-wide and every
one of them reported PASS.
quality.yml emits up to 18 job groups, not the four the older docs describe:
| Group | Jobs |
|---|---|
| PHP Quality | lint, phpcs, phpmd, psalm, phpstan, phpmetrics (matrix) |
| Vue Quality | eslint, stylelint (matrix) |
| Frontend | Frontend Build, Frontend Tests (unit), Frontend Check (…) per declared npm script |
| Tests | PHPUnit (PHP × Nextcloud matrix), Integration Tests (Newman), E2E Tests (Playwright) |
| Accessibility | axe-core (opt-in) |
| Supply chain | Security (composer), Security (npm), License (composer), License (npm), SBOM |
| Governance | Hydra Gates, Coverage Baseline Protection, Features Extract, Journeydoc Capture |
| Rollup | Quality Report |
Nextcloud's equivalent
Nextcloud ships workflow templates, not a reusable workflow: an app copies
lint-php-cs.yml, psalm.yml, lint-eslint.yml, node-test.yml,
phpunit-*.yml, appstore-build-publish.yml and so on from nextcloud/.github
into its own repo, and a sync-workflow-templates job keeps them refreshed.
| Conduction | Nextcloud | |
|---|---|---|
| Shape | one reusable workflow, thin callers | ~40 copied templates per app |
| Update path | change @main, all 18 apps follow | sync job re-copies templates |
| Action pinning | tags (actions/checkout@v4) | commit SHAs |
| Trigger | push + pull_request | pull_request, with dorny/paths-filter change detection and a summary job so branch protection still matches when skipped |
Neither shape is wrong. Ours propagates a fix instantly and gives a single rollup; theirs survives the org repo being unavailable and pins its supply chain harder. Action SHA-pinning is the one we should adopt — it is a supply-chain control, not a style preference.
PHP
Formatting — one standard, and it is Nextcloud's
Conduction code must pass nextcloud/coding-standard unchanged. We may be
stricter than Nextcloud; we may not be different from it.
That rule is newer than most of the code. Measured 2026-08-12 against
openregister's lib/, all 1,427 files failed Nextcloud's standard:
| php-cs-fixer rule | files affected |
|---|---|
curly_braces_position | 1,427 — 100% |
indentation_type | 1,409 — 98.7% |
phpdoc_align | 1,221 — 85.6% |
binary_operator_spaces | 1,104 — 77.4% |
trailing_comma_in_multiline | 693 — 48.6% |
cast_spaces | 676 — 47.4% |
concat_space | 583 — 40.9% |
The fleet had been formatting with a PEAR-derived PHPCS ruleset — four spaces,
next-line braces, (int) $x, 'a'.'b' — which is not a stricter standard but a
different dialect. Under the rule above it has to go, and it has.
Two tools, disjoint jurisdiction
| Concern | Tool | Where the config lives |
|---|---|---|
| Formatting — whitespace, braces, imports, quotes, casts | php-cs-fixer | conduction/coding-standard |
Semantics — named parameters, @spec, banned functions, removed NC APIs, line length | PHP_CodeSniffer | quality-config/ |
| Types | Psalm + PHPStan | quality-config/ |
Conduction\CodingStandard\Config extends Nextcloud's and merges a private
ADDITIONS array onto parent::getRules(). The package's invariant test fails
the build if ADDITIONS shares a single key with the parent set, if any parent
rule is dropped, or if any parent rule's value changed. The policy is therefore
enforced by construction, not by review — and each assertion carries a positive
control, because a suite that cannot fail is indistinguishable from one that
passes.
ADDITIONS is currently empty, which is a result rather than an omission.
Every rule this fleet wants beyond Nextcloud's is semantic, not typographic, and
php-cs-fixer cannot express any of them.
Why PHPCS had to be cut back, not just re-pointed
Left alone, the two tools contradict each other and the app becomes unfixable —
composer cs:fix and composer phpcs demand opposite things and neither can be
satisfied. That was the fleet's real state. Running the old ruleset over
php-cs-fixer-formatted code produced 111,932 findings, 111,747 of them
auto-fixable formatting:
Generic.WhiteSpace.DisallowTabIndent 62,123
Generic.WhiteSpace.ScopeIndent 35,030
Generic.Arrays.ArrayIndent 3,726
PEAR.Commenting.FunctionComment (param spacing) 2,797
PEAR.Functions.FunctionDeclaration (indent + brace) 2,576
Generic.Formatting.MultipleStatementAlignment 1,132
Generic.Formatting.SpaceAfterCast 909
Squiz.Strings.ConcatenationSpacing 534
Squiz.ControlStructures.ElseIfDeclaration 31
That last line is the shape of the whole problem: Squiz's sniff forbids the
elseif keyword Nextcloud's elseif fixer requires. Two tools cannot both be
right about one token.
With every formatting sniff removed, the same measurement yields 185 findings,
all semantic — 181 missing @spec, 2 over the 150-character line limit, 2 SPDX
end-char. The named-parameter and legacy-accessor sniffs fire zero times,
which is what stricter-but-compatible looks like when it is true rather than
assumed.
Docblock presence stays; docblock layout is phpdoc_align's. The alignment
codes are excluded individually rather than by dropping the sniff — losing the
presence requirement would be a real regression.
quality-config/tests/compatibility.sh makes this permanent: format a fixture,
run PHPCS over the result, fail on any formatting sniff.
What migrating an app looks like
Proven end-to-end on nextcloud-app-template
(PR #141):
php-cs-fixer Found 0 of 23 files that can be fixed
phpcs 39 violations, ALL WARNINGS (35 @spec, 4 SPDX). Zero errors.
cs:check and cs:fix used to lieThey are the script names nextcloud/coding-standard defines, and what
Nextcloud's own lint-php-cs.yml invokes. In this fleet they were aliases for
PHPCS — so a contributor running the documented Nextcloud command got four-space
PEAR reformatting. Worse, 17 of 18 apps carried nextcloud/coding-standard in
require-dev with no .php-cs-fixer.dist.php and no invocation anywhere: loaded,
and ready to reformat the whole codebase for whoever found it.
They now run php-cs-fixer, and the direct dependency is dropped — it arrives
transitively at a version conduction/coding-standard has tested against.
.php-cs-fixer.dist.php must start with require_once __DIR__ . '/vendor/autoload.php';.
php-cs-fixer includes the config before your autoloader runs, so without it the
run dies with Class not found — and in --format=json that fatal is reported as
zero files needing changes. It reads exactly like a pass.
The .editorconfig gap
No fleet app shipped an .editorconfig. Nextcloud core does. An editor
opening one of our PHP files fell back to whatever the user configured — and for
anyone whose defaults came from Nextcloud work, that is tabs, which the old PHPCS
ruleset then rejected.
Nextcloud's .editorconfig is now copied verbatim into every app
(indent_style = tab, indent_size = 4, two-space YAML and package*.json). It
agrees with the formatter instead of fighting it.
Static analysis and the Nextcloud version it is analysing against
Nextcloud's psalm.yml does two things ours does not:
- uses: icewind1991/nextcloud-version-matrix # reads appinfo/info.xml
- run: grep 'phpVersion="${{ steps.versions.outputs.php-min }}' psalm.xml
- run: composer remove nextcloud/ocp --dev --no-scripts
- run: composer require --dev nextcloud/ocp:dev-${{ steps.versions.outputs.branches-max }}
The analysed API surface is derived from what the app declares it supports, and
the run fails if psalm.xml does not pin the minimum PHP version.
Ours is static, and as of 2026-08-12 it is inconsistent with itself:
Declared in appinfo/info.xml | Analysed against | Tested against | |
|---|---|---|---|
| 16 apps | NC 32–34 | nextcloud/ocp:^31.0 (15 apps) | stable32 and/or stable33 |
Three consequences, in increasing order of seriousness:
- No app is tested on NC 34, which all of them claim to support.
- Static analysis runs one major below the declared minimum, so nothing added in 32/33/34 is visible to it.
- Nothing removed in 32/33/34 can be reported either. That is why the removal
of
\OC::$serverin NC 34 needed a hand-written PHPCS sniff to catch — the type checker was looking at NC 31 and could not see it.
No app's psalm.xml sets phpVersion, so our configs would fail Nextcloud's own
psalm gate outright. Adopting version-matrix derivation removes the whole class of
problem: the matrix can no longer drift from info.xml, because it is computed
from it.
What Psalm is actually checking
Every app runs errorLevel="4" and suppresses ~34 issue types. The suppression
list includes InvalidArgument, InvalidReturnType, InvalidReturnStatement,
InvalidMethodCall, UndefinedInterfaceMethod, TypeDoesNotContainType,
InvalidArrayOffset and EmptyArrayAccess, plus UndefinedClass for the whole
OCP\ namespace. Read a green Psalm leg accordingly.
PHPStan is level 5 fleet-wide, which the older docs state correctly.
Checks Nextcloud runs that we do not
| Nextcloud check | What it catches | Our coverage |
|---|---|---|
lint-info-xml.yml | appinfo/info.xml validated against the App Store XSD | none — quality.yml never reads info.xml |
occ app:check-code | use of private / deprecated server APIs | partial — one custom sniff for \OC::$server only |
occ integrity:sign-app | writes appinfo/signature.json into the package | none — see below |
reuse.yml | REUSE / SPDX compliance as a CI job | partial — a PHPCS sniff and a hydra gate, not the REUSE tool |
| PHPUnit on mariadb / mysql / oci / sqlite | database-portability bugs | pgsql only (one app opts into pgsql explicitly; the shared default is pgsql) |
Release and signing
Both paths build a tarball, attach it to a GitHub release and POST it to the App Store. One step differs and it is not cosmetic:
- Nextcloud runs
occ integrity:sign-app --privateKey --certificate, which writesappinfo/signature.jsoninside the package, then uploads. - We sign only the tarball (
openssl dgst -sha512) for the App Store API. There is nointegrity:sign-appstep inrelease.yml,release-beta.ymlorrelease-stable.yml.
The upload is accepted either way, but an installed app with no
appinfo/signature.json cannot be verified by Nextcloud's integrity checker.
We also package with rsync rather than krankerl; no app ships a
krankerl.toml. Nextcloud's template supports both, so this is a free choice.
Frontend
Here we are much closer to Nextcloud than on the backend — we use their configs directly.
| Conduction | Nextcloud (current apps) | |
|---|---|---|
| ESLint config | @nextcloud/eslint-config@^8.4.1 (17/18 apps) | @nextcloud/eslint-config@^9.0.1 |
| ESLint | ^8.56 — resolves to 8.57.1, end of life | 9.x / 10.x |
| Stylelint config | @nextcloud/stylelint-config@^2.4.0 | @nextcloud/stylelint-config@^3.2.2 |
| Stylelint | 15.x | 16.x |
| Unit tests | vitest (11 apps), jest (6), none (3) | vitest |
| E2E | Playwright | Cypress |
| Node in CI | hardcoded 20 | read from package.json engines, fallback ^24 |
Four things to fix, in order of how quietly they fail:
- Unquoted globs in the
stylelintscript. Thirteen apps runstylelint src/**/*.vue src/**/*.scss src/**/*.csswithout quotes, so the shell expands the glob and — withoutglobstar—src/**/matches only one directory level. Nested components are silently not linted. The four apps that quote it (procest,pipelinq,shillinq,doriath) check strictly more files than the other fourteen. A passing stylelint job does not currently mean the same thing in two apps. npm run lintiseslint srcwith no--max-warnings. Warnings never fail a build, so they accumulate indefinitely. (ESLint itself is running: the flat config loads correctly under 8.57.1 — verified from a live job log, not assumed.)- Three apps have no unit test script at all —
scholiq,portaliq,hermiq. The shared workflow detects this honestly and records a skip, which renders as a pass in the rollup. .prettierrcexists in 14 apps andprettieris a dependency in none. Noformatscript invokes it either. It is inert in CI but not in editors, where it tells Prettier to use 2-space indentation and double quotes for.tsfiles — both of which@nextcloud/eslint-configthen flags. Delete it or wire it up.
Consuming OpenRegister (ADR-083, ADR-084)
Almost every app depends on OpenRegister, and for a long time almost none of them said so where a reader — or a tool — could see it. The dominant shape was a lazy container lookup inside a private accessor:
private function getObjectService(): object {
return $this->container->get('OCA\OpenRegister\Service\ObjectService');
}
The dependency is announced nowhere: not in the constructor, not in the use
block, not in any type. It appears mid-method, as a string.
That is not a style complaint. Gate-7 reported 50 findings on pipelinq, every one a correctly-delegated endpoint — it could not see the delegation, because the only evidence was a string literal. A human review of the same code the same day reached the opposite conclusion about who was enforcing authorisation, and wrote it down. A reader with grep and an hour got the story backwards.
The four rules (ADR-083), enforced by gate-66
1. Inject an unconditional dependency; do not look it up. A class that needs
ObjectService on its normal path declares it as a constructor-promoted, typed
property — typed as the interface, see below.
Exception — the optional-capability path. Where a class reaches for OpenRegister only after establishing it is installed, the lookup stays. Deferring construction is the whole point of that shape, and injecting it would make the service unconstructable on an instance without OpenRegister — turning a clean "OpenRegister is not installed" message into a 500.
A guard can be written three ways and all three count: ask the app manager (
isInstalled,getInstalledApps), ask the autoloader (class_exists), or try-and-degrade (acatchthat logs and returns rather than rethrowing). Acatchthat rethrows is not a guard.
2. Never extends or implements an OpenRegister class. A reference in a
class header is fatal at autoload — it takes down the very route that would have
explained the problem. Compose instead. (Constructor injection is not that: it
resolves when the service is constructed, so a route that never constructs it
never fails.)
3. The default route stays core-only. The controller behind an app's start screen depends on core only, and publishes availability to the frontend so it can render an install prompt rather than an error:
$this->initialState->provideInitialState(
'openregister_available',
$this->appManager->isInstalled('openregister')
);
4. Check the version floor, do not install. An app MAY compare
IAppManager::getAppVersion('openregister') against its floor and report it. It
MUST NOT install or update OpenRegister: the only thing that can fetch from the
app store is OC\Installer — namespace OC, private API, no BC guarantee —
and it bypasses admin consent. Detect, inform, link to the store page.
The rule was too broad when first written, and the gate enforced it faithfully. The finding count went 1263 → 1010 → 883 → 441; the 822-site difference was code that was already correct, in one of the three guard forms above. A rule derived from one observed idiom will mistake every other correct idiom for debt, at fleet scale and with a straight face. Survey first, then write the rule.
Type-hint the contract, never the concrete class (ADR-084, gate-67)
Rule 1 is unenforceable on its own, and this is the half that makes it work.
An app that uses OpenRegister's ObjectService type-hints the published
interface:
use OCA\OpenRegister\Contract\ObjectServiceInterface;
public function __construct(
private readonly ObjectServiceInterface $objectService,
) {
}
and binds it once, in the composition root:
$context->registerServiceAlias(
ObjectServiceInterface::class,
'OCA\OpenRegister\Service\ObjectService'
);
Nextcloud autowires concrete classes across apps but not interfaces, so the binding has to be stated. An alias, not a factory: it resolves when something asks for the interface, so an app on an instance without OpenRegister fails at the route that needed the data rather than at registration.
Why not the concrete class. A leaf app cannot load a class from another Nextcloud app, so a typed parameter had no satisfiable test double:
TypeError: __construct(): Argument #4 ($objectService) must be of type
OCA\OpenRegister\Service\ObjectService, class@anonymous given
Ten of the sixteen consuming apps had hand-rolled a stub instead, declaring between 0 and 13 methods against a real class of 88 — union 23. Four declared zero: a shell that satisfies a type-hint and checks nothing.
Keep the concrete import where the concrete NAME is still used.
ObjectService::class as a container key, or instanceof ObjectService, names
the class itself. Import both in that case. Dropping the concrete import there
is silent damage: ::class does not require the class to exist, so
$container->get(ObjectService::class) quietly resolves to your app's own
namespace and looks up a key nobody registered, and instanceof becomes
permanently false. Neither errors at the point of the change.
gate-67 openregister-contract-parity keeps the two copies of the interface
— openregister's lib/Contract/ and the one shipped in hydra-gates — byte
identical, in both directions. Two definitions of a published contract is the
drift the ADR forbids, and an intention to keep them in step is not a mechanism.
Where the configuration lives
As of this change, the PHP quality configuration has one home:
quality-config/
in this repository, shipped inside the existing conduction/hydra-gates composer
package. An app pulls it with composer install and reduces its own files to
stubs:
<ruleset name="myapp">
<file>lib</file>
<rule ref="vendor/conduction/hydra-gates/quality-config/phpcs.xml"/>
</ruleset>
Same config in CI and on a laptop, which is the point — a config only CI sees becomes its own drift source.
The drift this replaces, measured across all 18 core apps on 2026-08-12:
| File | Distinct variants across 18 apps |
|---|---|
psalm.xml, phpstan.neon, playwright.config.ts, code-quality.yml | 18 each |
eslint.config.js | 17 |
phpmd.xml | 15 |
vitest.config.js | 14 |
phpcs.xml, stylelint.config.js | 6 each |
NamedParametersSniff.php (a custom rule) | 6 |
.prettierrc | 1 |
psalm.xml and the frontend configs are not stub-able yet — Psalm has no config
inheritance and ESLint flat config resolves plugins relative to the config file.
Both are covered in the quality-config/ README.
Package registry
Shared configuration only stops drifting if every app pulls it the same way, so the distribution channel is part of the standard, not an implementation detail.
| Package | Registry | Contains | Consumed by |
|---|---|---|---|
conduction/coding-standard | Packagist | php-cs-fixer config extending Nextcloud's | every app, require-dev |
conduction/hydra-gates | Packagist | the mechanical gates, quality-config/ (PHPCS, PHPMD, PHPStan base, custom sniffs) and contracts/ — the OpenRegister interfaces apps type-hint (ADR-084) | every app, require-dev |
@conduction/nextcloud-vue | npm | shared Vue components and the ESLint / Stylelint config | every app, dependencies |
Both composer packages are built from repositories that also do other things —
conduction/hydra-gates is the root package of ConductionNL/.github, which also
hosts this documentation site. One repository can publish exactly one composer
package, which is why the coding standard needed a repository of its own.
Registration is not cosmetic
Before registration, an app consuming conduction/hydra-gates needed this in its
own composer.json:
"repositories": [
{ "type": "vcs", "url": "https://github.com/ConductionNL/.github.git", "no-api": true }
]
That block is a per-app file, and per-app files are what drift — it is the same
failure mode the shared config exists to end. no-api: true also made composer
clone the entire .github repository, docs site included, on every install.
Registration deletes the block from all 18 apps.
Constraints float; they are not pinned
Apps require ^1.0, not an exact version. A pin is a silent expiry date: 22 repos
once sat on gate package v1.0.1 while 16 gates were dead fleet-wide and every one
of them reported PASS. Hold a package still only for a stated reason, in that app,
with the reason written next to the constraint.
Two distribution paths in one package, and only one of them needs a tag
conduction/hydra-gates ships two kinds of thing, and they reach an app by
different routes. Confusing them costs a release.
| what | how it reaches an app | needs a tag? |
|---|---|---|
the gate scripts (scripts/, the runner) | the shared CI workflow fetches them from .github@main | no — a merge to main is live immediately, in every app's next run |
contracts/ — the OpenRegister interfaces | composer, from the app's vendor/ | yes — apps pin ^1.0, so nothing reaches them until a version is tagged |
So a fix to a checker is live on merge, while a change to a published interface
is invisible until the tag exists and each app's composer.lock moves.
Measured 2026-08-15, and the failure mode is quiet. openregister merged
lib/Contract/, then bumped its lock to v1.8.0 specifically so gate-67 would
start comparing the two copies. It still reported:
[gate-67] openregister-contract-parity: NOT APPLICABLE — lib/Contract/ exists but
no shipped copy was found to compare it against. NOT a pass — nothing was verified.
The gate looked for the shipped copy in vendor/conduction/hydra-gates/…, and
the Hydra Gates job never runs composer install — it fetches the runner from
main, so that job has no vendor/ at all and never will. The gate could only
ever skip. It now also looks for the copy sitting alongside the running script,
which is the path that exists in CI (.github#465).
Two things worth carrying:
- A gate that has only ever skipped has not been shown to work.
NOT APPLICABLEis deliberately not a pass, and this is why: the wording was correct and the gate was still useless. - Check the gate's verdict in a real run, not just that the change that should enable it has landed.
Publishing, and how to tell it actually published
Submitting a repository creates a push webhook on it automatically —
https://packagist.org/api/github, push events. There is nothing to wire up by
hand and nothing for gh to configure; GitHub Apps cannot be installed through
the API in any case.
Verify against the endpoint Composer actually reads. Packagist serves package metadata from two places, and they do not update together:
| endpoint | who reads it | freshness |
|---|---|---|
repo.packagist.org/p2/<vendor>/<pkg>.json | Composer | current |
packagist.org/packages/<vendor>/<pkg>.json | the website | lags, sometimes by tens of minutes |
Measured 2026-08-12: conduction/hydra-gates v1.7.0 was tagged and pushed. The
web endpoint showed v1.6.0 as the newest tag for the next half hour, while the
p2 endpoint already had v1.7.0 — and composer require conduction/hydra-gates:^1.0
resolved to v1.7.0 (b9c6520a) throughout. Nothing was broken. The instrument was.
That mistake is worth naming because it produced a confident, wrong diagnosis: a
webhook returning 202, a package that "had not updated", and a missing Packagist
GitHub App all pointed at a publishing failure that was not happening.
So the check is one command, and it is the one Composer would make:
curl -s https://repo.packagist.org/p2/<vendor>/<pkg>.json \
| jq -r '.packages | to_entries[0].value[0] | "\(.version) \(.source.reference[0:8])"'
Compare that SHA to git rev-parse HEAD. Three things that are not evidence:
a ping delivery (Packagist sends one on install, it proves only that the
endpoint answers a handshake), an HTTP 202 (it means accepted for processing),
and package.time on the web endpoint (that is the package's creation
timestamp and never moves).
npm
@conduction/nextcloud-vue was already published, so homing the frontend config
there adds export paths to an existing package rather than creating a new one.
Note its dist-tags: apps consume vue3, currently 2.2.0-vue3.x. The latest
tag is 1.0.0-beta.3, an old Vue 2 build — installing without a tag or an explicit
version gets the wrong library.
An upstream tool defect does not get to block a release
Third-party tooling occasionally refuses to run against this fleet for reasons that have nothing to do with our code. When that happens there are three responses, and only one of them is allowed.
The worked example. nextcloud/openapi-extractor maps an app's <licence>
to an SPDX identifier through a hardcoded match() in src/Helpers.php. It
accepts agpl / mit / mpl / apache / gpl3 and five SPDX spellings, and
calls Logger::panic() — a fatal, not a warning — on anything else:
PHP Fatal error: license: Unable to convert EUPL-1.2 to SPDX identifier
EUPL-1.2 is a perfectly valid SPDX identifier and is the licence every
Conduction app ships under. The tool therefore could not run against any of
them. app-versions' openapi job had never passed once in its entire history.
What we do not do.
- Relabel the licence to suit the tool. The licence is a legal fact, not a configuration value, and it would be wrong in the generated spec.
- Mark the step
continue-on-errorso the job reports green. This is worse than the red: a job that cannot run now looks exactly like one that passed, which is the failure mode most of this page exists to prevent. - Wait for upstream. Upstream
mainstill carried the same list months after the defect was reachable. A release cadence cannot depend on someone else's merge queue.
What we do. Patch the dependency at install time with
cweagans/composer-patches, in
the vendor-bin/ namespace that already pins the tool:
// vendor-bin/openapi-extractor/composer.json
{
"require-dev": {
"cweagans/composer-patches": "^1.7",
"nextcloud/openapi-extractor": "v1.8.7" // pin the exact version
},
"config": { "allow-plugins": { "cweagans/composer-patches": true } },
"extra": {
"patches": {
"nextcloud/openapi-extractor": {
"Allow EUPL-1.1 / EUPL-1.2 as SPDX identifiers": "patches/0001-allow-eupl-spdx-identifiers.patch"
}
}
}
}
Three properties make this safe rather than a fork:
- The version is pinned exactly. A patch against a floating range is a
time bomb; against
v1.8.7it either applies or it does not. - It fails loudly. If the pin moves and the patch stops applying, composer
aborts the install. It cannot silently revert to the broken behaviour —
which is precisely what a
sedin a workflow step would do. - The patch file carries its own rationale in its header, so it can be sent upstream unchanged, and so the next person reads why before what.
Then expect the tool to find real work. Removing the blocker is not the end
of it. With the licence fatal gone, the extractor reached app-versions'
controllers for the first time and reported 50 genuine findings — 27 missing
@return annotations, 11 missing @param docs, and 12 uses of getParam()
where a typed method parameter belongs. Those are ours to fix, and a gate that
reports our own debt is working correctly. Budget for that second half.
One caution when writing the annotations it then demands: derive the response
shapes from the method's actual return statements. Where a payload comes from
a service whose shape is not visible at the call site, annotate it as a generic
map rather than a guessed set of keys — a wrong shape in a published API spec
is worse than a loose one.
Corrections to older pages
- "Code style — PHPCS (PSR-12)", in development-pipeline.md and contributing.md, is wrong. The ruleset is PEAR-derived. Four PSR-2/PSR-12 sniffs are pulled in individually; the standard as a whole is not.
- "four parallel quality gates" understates the pipeline by an order of magnitude — see the table at the top of this page.
- "Hydra … coming soon" is stale:
enable-hydra-gates: trueis set in all 18 core apps today. - "There are no development builds", in
release-process.md, is contradicted by
release-development.yml, which exists in 14 of 18 apps and calls the shared beta release workflow withchannel: dev.
Further reading
- Development Pipeline — branch flow and release triggers
- Contributing — PR checklist and commit conventions
- ADR-083 and ADR-084 in
ConductionNL/hydra— how an app depends on OpenRegister, and the contract it type-hints quality-config/README — the mechanism, and the phpcs behaviour it was built on- nextcloud/.github workflow templates
- nextcloud/coding-standard