From 261571ca4ac120546a9ea8296dc66d347de0e3a2 Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Tue, 9 Jun 2026 09:52:26 +0200 Subject: [PATCH 1/7] feat(release-tools): scaffold PHP package + milestone domain (step 0+1) First step of migrating the logic-heavy release scripts from bash to a unit-tested PHP package. This lands the scaffold and the pure milestone domain only - no I/O, no workflow changes, nothing cut over yet. It sits beside the bash and is consumed in a later step. - tools/release: composer package (Symfony-Console-ready), PHPUnit 12, CI. - Domain (pure, zero mocks): - Version - parse a tag into major/minor/patch + prerelease/first-beta - MilestonePlan - current/next/upcoming names; first beta -> next major (N+1) - DueDate - validate YYYY-MM-DD, convert to ISO - RepoList - merge config + tag-only lists (sorted, unique) - AuditExpectation - expected milestone state + orphan rule - 22 tests / 69 assertions. These encode the exact rules that caused bugs in the bash version (first-beta off-by-one, the two-open-patch invariant) and catch them with plain assertions - no fixtures or fake-gh needed. Next: wire these behind a mockable GitHub client + Console commands (milestones:update / milestones:audit), then retire update-milestones.sh. Signed-off-by: skjnldsv --- .github/workflows/test-release-tools.yml | 40 + tools/release/.gitignore | 2 + tools/release/README.md | 37 + tools/release/composer.json | 28 + tools/release/composer.lock | 1703 ++++++++++++++++++ tools/release/phpunit.xml.dist | 18 + tools/release/src/AuditExpectation.php | 50 + tools/release/src/DueDate.php | 29 + tools/release/src/MilestonePlan.php | 60 + tools/release/src/RepoList.php | 40 + tools/release/src/Version.php | 46 + tools/release/tests/AuditExpectationTest.php | 32 + tools/release/tests/DueDateTest.php | 37 + tools/release/tests/MilestonePlanTest.php | 44 + tools/release/tests/RepoListTest.php | 40 + tools/release/tests/VersionTest.php | 42 + 16 files changed, 2248 insertions(+) create mode 100644 .github/workflows/test-release-tools.yml create mode 100644 tools/release/.gitignore create mode 100644 tools/release/README.md create mode 100644 tools/release/composer.json create mode 100644 tools/release/composer.lock create mode 100644 tools/release/phpunit.xml.dist create mode 100644 tools/release/src/AuditExpectation.php create mode 100644 tools/release/src/DueDate.php create mode 100644 tools/release/src/MilestonePlan.php create mode 100644 tools/release/src/RepoList.php create mode 100644 tools/release/src/Version.php create mode 100644 tools/release/tests/AuditExpectationTest.php create mode 100644 tools/release/tests/DueDateTest.php create mode 100644 tools/release/tests/MilestonePlanTest.php create mode 100644 tools/release/tests/RepoListTest.php create mode 100644 tools/release/tests/VersionTest.php diff --git a/.github/workflows/test-release-tools.yml b/.github/workflows/test-release-tools.yml new file mode 100644 index 00000000000..9cbe98aca2e --- /dev/null +++ b/.github/workflows/test-release-tools.yml @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: MIT + +name: Test release tools (PHP) + +on: + push: + branches: + - main + paths: + - 'tools/release/**' + pull_request: + paths: + - 'tools/release/**' + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: tools/release + + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Set up PHP + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2.37.1 + with: + php-version: '8.3' + tools: composer + + - name: Install dependencies + run: composer install --no-interaction --no-progress + + - name: PHPUnit + run: composer test diff --git a/tools/release/.gitignore b/tools/release/.gitignore new file mode 100644 index 00000000000..7f78132bebb --- /dev/null +++ b/tools/release/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +/.phpunit.result.cache diff --git a/tools/release/README.md b/tools/release/README.md new file mode 100644 index 00000000000..1939c1ad076 --- /dev/null +++ b/tools/release/README.md @@ -0,0 +1,37 @@ + +# Release tools (PHP) + +Unit-tested PHP for the release automation, migrated incrementally from the +bash scripts in `.github/scripts/`. The logic-heavy, API-driven scripts move +here where they get real types, return values and PHPUnit coverage; the +filesystem/packaging scripts stay in bash. + +## Layout + +- `src/` — domain logic, pure (no I/O), mockable services, and (later) commands. +- `tests/` — PHPUnit. + +This first step ships the milestone **domain** (no I/O): + +| class | purpose | +|---|---| +| `Version` | parse a release tag → major/minor/patch, isPrerelease, isFirstBeta | +| `MilestonePlan` | current/next/upcoming milestone names; first beta → next major (N+1) | +| `DueDate` | validate `YYYY-MM-DD`, convert to the ISO form the API wants | +| `RepoList` | merge the config + tag-only lists (sorted, unique) | +| `AuditExpectation` | expected milestone state + orphan rule for the audit | + +Next steps wire these behind a GitHub client wrapper and Symfony Console +commands (`milestones:update`, `milestones:audit`), then retire the +corresponding bash scripts once at parity. + +## Develop + +```bash +cd tools/release +composer install +composer test +``` diff --git a/tools/release/composer.json b/tools/release/composer.json new file mode 100644 index 00000000000..1861677a24a --- /dev/null +++ b/tools/release/composer.json @@ -0,0 +1,28 @@ +{ + "name": "nextcloud/release-tools", + "description": "Release automation domain logic and commands (migrated from .github/scripts)", + "license": "MIT", + "type": "project", + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12" + }, + "autoload": { + "psr-4": { + "Nextcloud\\ReleaseTools\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Nextcloud\\ReleaseTools\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit" + }, + "config": { + "sort-packages": true + } +} diff --git a/tools/release/composer.lock b/tools/release/composer.lock new file mode 100644 index 00000000000..02b220284a1 --- /dev/null +++ b/tools/release/composer.lock @@ -0,0 +1,1703 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "4440aa21f4c1de239a32e9e1840356e6", + "packages": [], + "packages-dev": [ + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "12.5.7", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "186dab580576598076de6818596d12b61801880e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/186dab580576598076de6818596d12b61801880e", + "reference": "186dab580576598076de6818596d12b61801880e", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.28" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.7" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-06-01T13:24:19+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T14:04:18+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:58+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:16+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:38+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "12.5.29", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "9aa66a47db3ea70f1a468e66dd969f67e594945a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9aa66a47db3ea70f1a468e66dd969f67e594945a", + "reference": "9aa66a47db3ea70f1a468e66dd969f67e594945a", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.7", + "phpunit/php-file-iterator": "^6.0.1", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", + "sebastian/diff": "^7.0.0", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.3", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.4", + "sebastian/version": "^6.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.29" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-06-04T06:14:42+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-05-17T05:29:34+00:00" + }, + { + "name": "sebastian/comparator", + "version": "7.1.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "7c65c1e79836812819705b473a90c12399542485" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-05-21T04:45:25+00:00" + }, + { + "name": "sebastian/complexity", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0", + "symfony/process": "^7.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:46+00:00" + }, + { + "name": "sebastian/environment", + "version": "8.1.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.26" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:40:20+00:00" + }, + { + "name": "sebastian/exporter", + "version": "7.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-05-20T04:37:17+00:00" + }, + { + "name": "sebastian/global-state", + "version": "8.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^12.5.28" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:10:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:22:07+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:57:48+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:17+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:44:59+00:00" + }, + { + "name": "sebastian/type", + "version": "6.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "82ff822c2edc46724be9f7411d3163021f602773" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-05-20T06:45:45+00:00" + }, + { + "name": "sebastian/version", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T05:00:38+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.3" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/tools/release/phpunit.xml.dist b/tools/release/phpunit.xml.dist new file mode 100644 index 00000000000..fb3ad6dcc65 --- /dev/null +++ b/tools/release/phpunit.xml.dist @@ -0,0 +1,18 @@ + + + + + tests + + + + + src + + + diff --git a/tools/release/src/AuditExpectation.php b/tools/release/src/AuditExpectation.php new file mode 100644 index 00000000000..f4d77fe6838 --- /dev/null +++ b/tools/release/src/AuditExpectation.php @@ -0,0 +1,50 @@ + milestone name(s) that should be closed */ + public function releasedMilestones(): array + { + return MilestonePlan::currentMilestones($this->latestStable); + } + + public function nextMilestone(): string + { + return MilestonePlan::nextMilestone($this->latestStable); + } + + public function upcomingMilestone(): string + { + return MilestonePlan::upcomingMilestone($this->latestStable); + } + + /** + * Whether an open milestone "Nextcloud X.Y.P" is an orphan (its version is + * already released, so it should have been closed). + */ + public function isOrphan(int $minor, int $patch): bool + { + return $minor === $this->latestStable->minor + && $patch <= $this->latestStable->patch; + } +} diff --git a/tools/release/src/DueDate.php b/tools/release/src/DueDate.php new file mode 100644 index 00000000000..abcbf4fb4b5 --- /dev/null +++ b/tools/release/src/DueDate.php @@ -0,0 +1,29 @@ +major + 1); + } + + /** + * The milestone name(s) to close for a stable release. Initial releases + * (.0.0) may use the short "Nextcloud N" form, so both are candidates. + * + * @return list + */ + public static function currentMilestones(Version $version): array + { + $full = self::name($version->major, $version->minor, $version->patch); + if ($version->patch === 0 && $version->minor === 0) { + return [$full, self::name($version->major)]; + } + return [$full]; + } + + /** The next patch milestone (issues move here). */ + public static function nextMilestone(Version $version): string + { + return self::name($version->major, $version->minor, $version->patch + 1); + } + + /** The upcoming patch milestone (created so two stay open). */ + public static function upcomingMilestone(Version $version): string + { + return self::name($version->major, $version->minor, $version->patch + 2); + } +} diff --git a/tools/release/src/RepoList.php b/tools/release/src/RepoList.php new file mode 100644 index 00000000000..c6798b69f47 --- /dev/null +++ b/tools/release/src/RepoList.php @@ -0,0 +1,40 @@ + ...$lists decoded JSON arrays + * @return list + */ + public static function merge(array ...$lists): array + { + $repos = []; + foreach ($lists as $list) { + foreach ($list as $item) { + if (is_array($item) && isset($item['repo']) && is_string($item['repo'])) { + $repos[] = $item['repo']; + } elseif (is_string($item)) { + $repos[] = $item; + } + } + } + $repos = array_values(array_unique($repos)); + sort($repos, SORT_STRING); + return $repos; + } +} diff --git a/tools/release/src/Version.php b/tools/release/src/Version.php new file mode 100644 index 00000000000..75cf7097b06 --- /dev/null +++ b/tools/release/src/Version.php @@ -0,0 +1,46 @@ +assertSame(['Nextcloud 33.0.4'], $e->releasedMilestones()); + $this->assertSame('Nextcloud 33.0.5', $e->nextMilestone()); + $this->assertSame('Nextcloud 33.0.6', $e->upcomingMilestone()); + } + + public function testOrphanDetection(): void + { + $e = new AuditExpectation(Version::fromTag('v33.0.4')); + $this->assertTrue($e->isOrphan(0, 3), 'older patch on same minor is an orphan'); + $this->assertTrue($e->isOrphan(0, 4), 'the released patch is an orphan if still open'); + $this->assertFalse($e->isOrphan(0, 5), 'the next patch is not an orphan'); + $this->assertFalse($e->isOrphan(1, 1), 'a different minor is not an orphan'); + } +} diff --git a/tools/release/tests/DueDateTest.php b/tools/release/tests/DueDateTest.php new file mode 100644 index 00000000000..4cb333ce9d9 --- /dev/null +++ b/tools/release/tests/DueDateTest.php @@ -0,0 +1,37 @@ +assertSame('2026-07-02T00:00:00Z', DueDate::toIso('2026-07-02')); + } + + /** @return iterable */ + public static function invalid(): iterable + { + yield 'slashes' => ['2026/07/02']; + yield 'short' => ['2026-7-2']; + yield 'empty' => ['']; + yield 'words' => ['next thursday']; + } + + #[DataProvider('invalid')] + public function testInvalidThrows(string $date): void + { + $this->assertFalse(DueDate::isValid($date)); + $this->expectException(\InvalidArgumentException::class); + DueDate::toIso($date); + } +} diff --git a/tools/release/tests/MilestonePlanTest.php b/tools/release/tests/MilestonePlanTest.php new file mode 100644 index 00000000000..f70777c6e76 --- /dev/null +++ b/tools/release/tests/MilestonePlanTest.php @@ -0,0 +1,44 @@ +assertSame(['Nextcloud 33.0.4'], MilestonePlan::currentMilestones($v)); + $this->assertSame('Nextcloud 33.0.5', MilestonePlan::nextMilestone($v)); + $this->assertSame('Nextcloud 33.0.6', MilestonePlan::upcomingMilestone($v)); + } + + public function testInitialReleaseAlsoClosesShortName(): void + { + $v = Version::fromTag('v34.0.0'); + $this->assertSame(['Nextcloud 34.0.0', 'Nextcloud 34'], MilestonePlan::currentMilestones($v)); + $this->assertSame('Nextcloud 34.0.1', MilestonePlan::nextMilestone($v)); + $this->assertSame('Nextcloud 34.0.2', MilestonePlan::upcomingMilestone($v)); + } + + public function testFirstBetaCreatesNextMajor(): void + { + // The bug that bit us: first beta of N must create N+1, not N. + $this->assertSame('Nextcloud 35', MilestonePlan::firstBetaMilestone(Version::fromTag('v34.0.0beta1'))); + $this->assertSame('Nextcloud 36', MilestonePlan::firstBetaMilestone(Version::fromTag('v35.0.0beta1'))); + } + + public function testName(): void + { + $this->assertSame('Nextcloud 34', MilestonePlan::name(34)); + $this->assertSame('Nextcloud 34.0.2', MilestonePlan::name(34, 0, 2)); + } +} diff --git a/tools/release/tests/RepoListTest.php b/tools/release/tests/RepoListTest.php new file mode 100644 index 00000000000..28095ca5e28 --- /dev/null +++ b/tools/release/tests/RepoListTest.php @@ -0,0 +1,40 @@ + 'server', 'repo' => 'nextcloud/server'], + ['id' => 'activity', 'repo' => 'nextcloud/activity'], + ]; + $tagOnly = ['nextcloud/activity', 'nextcloud/updater']; + + $this->assertSame( + ['nextcloud/activity', 'nextcloud/server', 'nextcloud/updater'], + RepoList::merge($config, $tagOnly), + ); + } + + public function testEmptyTagOnly(): void + { + $config = [['repo' => 'nextcloud/server']]; + $this->assertSame(['nextcloud/server'], RepoList::merge($config, [])); + } + + public function testIgnoresEntriesWithoutRepo(): void + { + $config = [['id' => 'broken'], ['repo' => 'nextcloud/server']]; + $this->assertSame(['nextcloud/server'], RepoList::merge($config)); + } +} diff --git a/tools/release/tests/VersionTest.php b/tools/release/tests/VersionTest.php new file mode 100644 index 00000000000..7fedc3da8a2 --- /dev/null +++ b/tools/release/tests/VersionTest.php @@ -0,0 +1,42 @@ + + */ + public static function tags(): iterable + { + // tag, major, minor, patch, isPrerelease, isFirstBeta + yield 'stable patch' => ['v34.0.4', 34, 0, 4, false, false]; + yield 'stable patch no-v' => ['34.0.4', 34, 0, 4, false, false]; + yield 'initial major' => ['v34.0.0', 34, 0, 0, false, false]; + yield 'rc' => ['v34.0.0rc1', 34, 0, 0, true, false]; + yield 'beta (not first)' => ['v34.0.1beta2', 34, 0, 1, true, false]; + yield 'first beta' => ['v35.0.0beta1', 35, 0, 0, true, true]; + yield 'alpha' => ['v36.0.0alpha1', 36, 0, 0, true, false]; + yield 'minor bump' => ['v34.1.2', 34, 1, 2, false, false]; + } + + #[DataProvider('tags')] + public function testFromTag(string $tag, int $major, int $minor, int $patch, bool $pre, bool $firstBeta): void + { + $v = Version::fromTag($tag); + $this->assertSame($major, $v->major); + $this->assertSame($minor, $v->minor); + $this->assertSame($patch, $v->patch); + $this->assertSame($pre, $v->isPrerelease); + $this->assertSame($firstBeta, $v->isFirstBeta); + } +} From a638a47e99254b3b76cfd3788bfc5cd5fad58197 Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Tue, 9 Jun 2026 10:15:15 +0200 Subject: [PATCH 2/7] feat(release-tools): migrate milestone mover + tagger to PHP, wire workflows Step 2 of the bash->PHP migration. The milestone mover (update-milestones.sh + audit-milestones.sh) and the tagger (tag-repo.sh) are reimplemented as unit-tested PHP commands and the live workflows now call them. The bash scripts and their fake-gh harness are removed. tools/release additions: - GitHub\GitHubApi interface with a knplabs-backed adapter (KnpGitHubApi) and an in-memory FakeGitHubApi (journal of mutations) for tests. Tags are created as lightweight refs via the git-refs API (no clone/push). Issue moves skip pull requests. - Services: MilestoneUpdater, MilestoneAuditor, RepoTagger. - Domain: TagPolicy (server-repo immutability), ReleaseConfig (config->major, latest-stable, repo list). - Commands milestones:update, milestones:audit, repo:tag (bin/console). - 55 PHPUnit tests covering every release shape + tagger combination via the fake (each test file documents what/why in its docblock). Workflows: - release-milestones.yml and release-tag.yml set up PHP, composer install, and call the console commands (same inputs, same RELEASE_TOKEN). - test-release-tools.yml runs on all pushes/PRs (no path filter). Removed: update-milestones.sh, audit-milestones.sh, tag-repo.sh, tests/milestone-scripts/**, test-milestone-scripts.yml. Release auto-logic is documented in tools/release/README.md. Signed-off-by: skjnldsv --- .github/scripts/README.md | 46 +- .github/scripts/audit-milestones.sh | 243 -- .github/scripts/tag-repo.sh | 151 -- .github/scripts/update-milestones.sh | 377 --- .github/workflows/release-milestones.yml | 14 +- .github/workflows/release-tag.yml | 89 +- .github/workflows/test-milestone-scripts.yml | 44 - .github/workflows/test-release-tools.yml | 4 - tests/milestone-scripts/configs/master.json | 3 - tests/milestone-scripts/configs/stable33.json | 3 - tests/milestone-scripts/configs/stable34.json | 3 - tests/milestone-scripts/configs/tag-only.json | 1 - .../configs/unit-config.json | 4 - .../configs/unit-tagonly.json | 4 - tests/milestone-scripts/fake-gh.sh | 169 -- tests/milestone-scripts/run.sh | 120 - .../scenarios/audit-healthy/args.env | 2 - .../scenarios/audit-healthy/expected/exit | 1 - .../audit-healthy/expected/journal.txt | 0 .../audit-healthy/expected/stdout.txt | 11 - .../scenarios/audit-healthy/fixture.json | 11 - .../scenarios/audit-master-inference/args.env | 2 - .../audit-master-inference/expected/exit | 1 - .../expected/journal.txt | 0 .../expected/stdout.txt | 1 - .../audit-master-inference/fixture.json | 5 - .../scenarios/audit-missing-next/args.env | 2 - .../audit-missing-next/expected/exit | 1 - .../audit-missing-next/expected/journal.txt | 0 .../audit-missing-next/expected/stdout.txt | 12 - .../scenarios/audit-missing-next/fixture.json | 10 - .../scenarios/audit-missing-upcoming/args.env | 2 - .../audit-missing-upcoming/expected/exit | 1 - .../expected/journal.txt | 0 .../expected/stdout.txt | 12 - .../audit-missing-upcoming/fixture.json | 10 - .../scenarios/audit-no-stable/args.env | 2 - .../scenarios/audit-no-stable/expected/exit | 1 - .../audit-no-stable/expected/journal.txt | 0 .../audit-no-stable/expected/stdout.txt | 1 - .../scenarios/audit-no-stable/fixture.json | 5 - .../scenarios/audit-orphan/args.env | 2 - .../scenarios/audit-orphan/expected/exit | 1 - .../audit-orphan/expected/journal.txt | 0 .../audit-orphan/expected/stdout.txt | 12 - .../scenarios/audit-orphan/fixture.json | 12 - .../scenarios/audit-released-open/args.env | 2 - .../audit-released-open/expected/exit | 1 - .../audit-released-open/expected/journal.txt | 0 .../audit-released-open/expected/stdout.txt | 13 - .../audit-released-open/fixture.json | 11 - .../scenarios/audit-upcoming-no-due/args.env | 2 - .../audit-upcoming-no-due/expected/exit | 1 - .../expected/journal.txt | 0 .../audit-upcoming-no-due/expected/stdout.txt | 12 - .../audit-upcoming-no-due/fixture.json | 11 - .../scenarios/bad-due-date/args.env | 3 - .../scenarios/bad-due-date/expected/exit | 1 - .../bad-due-date/expected/journal.txt | 0 .../bad-due-date/expected/stdout.txt | 1 - .../scenarios/bad-due-date/fixture.json | 1 - .../scenarios/due-date-existing/args.env | 3 - .../scenarios/due-date-existing/expected/exit | 1 - .../due-date-existing/expected/journal.txt | 3 - .../due-date-existing/expected/stdout.txt | 16 - .../scenarios/due-date-existing/fixture.json | 11 - .../scenarios/due-date/args.env | 3 - .../scenarios/due-date/expected/exit | 1 - .../scenarios/due-date/expected/journal.txt | 3 - .../scenarios/due-date/expected/stdout.txt | 15 - .../scenarios/due-date/fixture.json | 10 - .../scenarios/existing-upcoming/args.env | 2 - .../scenarios/existing-upcoming/expected/exit | 1 - .../existing-upcoming/expected/journal.txt | 2 - .../existing-upcoming/expected/stdout.txt | 15 - .../scenarios/existing-upcoming/fixture.json | 15 - .../scenarios/first-beta-idempotent/args.env | 2 - .../first-beta-idempotent/expected/exit | 1 - .../expected/journal.txt | 0 .../first-beta-idempotent/expected/stdout.txt | 8 - .../first-beta-idempotent/fixture.json | 9 - .../scenarios/first-beta/args.env | 2 - .../scenarios/first-beta/expected/exit | 1 - .../scenarios/first-beta/expected/journal.txt | 1 - .../scenarios/first-beta/expected/stdout.txt | 8 - .../scenarios/first-beta/fixture.json | 5 - .../scenarios/first-stable-altname/args.env | 2 - .../first-stable-altname/expected/exit | 1 - .../first-stable-altname/expected/journal.txt | 3 - .../first-stable-altname/expected/stdout.txt | 15 - .../first-stable-altname/fixture.json | 9 - .../scenarios/issue-pagination/args.env | 2 - .../scenarios/issue-pagination/expected/exit | 1 - .../issue-pagination/expected/journal.txt | 152 -- .../issue-pagination/expected/stdout.txt | 164 -- .../scenarios/issue-pagination/fixture.json | 775 ------ .../scenarios/missing-next/args.env | 2 - .../scenarios/missing-next/expected/exit | 1 - .../missing-next/expected/journal.txt | 4 - .../missing-next/expected/stdout.txt | 16 - .../scenarios/missing-next/fixture.json | 13 - .../scenarios/paginated-lookup/args.env | 2 - .../scenarios/paginated-lookup/expected/exit | 1 - .../paginated-lookup/expected/journal.txt | 3 - .../paginated-lookup/expected/stdout.txt | 15 - .../scenarios/paginated-lookup/fixture.json | 730 ------ .../scenarios/patch-release/args.env | 2 - .../scenarios/patch-release/expected/exit | 1 - .../patch-release/expected/journal.txt | 4 - .../patch-release/expected/stdout.txt | 16 - .../scenarios/patch-release/fixture.json | 15 - .../scenarios/prerelease-noop/args.env | 2 - .../scenarios/prerelease-noop/expected/exit | 1 - .../prerelease-noop/expected/journal.txt | 0 .../prerelease-noop/expected/stdout.txt | 1 - .../scenarios/prerelease-noop/fixture.json | 1 - tests/milestone-scripts/unit.sh | 83 - tools/release/README.md | 107 +- tools/release/bin/console | 20 + tools/release/composer.json | 5 +- tools/release/composer.lock | 2067 ++++++++++++++++- .../src/Command/MilestonesAuditCommand.php | 67 + .../src/Command/MilestonesUpdateCommand.php | 72 + tools/release/src/Command/RepoTagCommand.php | 69 + tools/release/src/GitHub/FakeGitHubApi.php | 133 ++ tools/release/src/GitHub/GitHubApi.php | 48 + tools/release/src/GitHub/KnpGitHubApi.php | 157 ++ tools/release/src/GitHub/Milestone.php | 21 + tools/release/src/MilestoneAuditor.php | 95 + tools/release/src/MilestoneUpdater.php | 177 ++ tools/release/src/ReleaseConfig.php | 88 + tools/release/src/RepoTagger.php | 58 + tools/release/src/TagPolicy.php | 48 + tools/release/src/TagResult.php | 20 + tools/release/tests/AuditExpectationTest.php | 6 + tools/release/tests/DueDateTest.php | 6 + tools/release/tests/MilestoneAuditorTest.php | 86 + tools/release/tests/MilestonePlanTest.php | 7 + tools/release/tests/MilestoneUpdaterTest.php | 191 ++ tools/release/tests/ReleaseConfigTest.php | 65 + tools/release/tests/RepoListTest.php | 7 + tools/release/tests/RepoTaggerTest.php | 94 + tools/release/tests/TagPolicyTest.php | 45 + tools/release/tests/VersionTest.php | 6 + 144 files changed, 3783 insertions(+), 3611 deletions(-) delete mode 100755 .github/scripts/audit-milestones.sh delete mode 100755 .github/scripts/tag-repo.sh delete mode 100755 .github/scripts/update-milestones.sh delete mode 100644 .github/workflows/test-milestone-scripts.yml delete mode 100644 tests/milestone-scripts/configs/master.json delete mode 100644 tests/milestone-scripts/configs/stable33.json delete mode 100644 tests/milestone-scripts/configs/stable34.json delete mode 100644 tests/milestone-scripts/configs/tag-only.json delete mode 100644 tests/milestone-scripts/configs/unit-config.json delete mode 100644 tests/milestone-scripts/configs/unit-tagonly.json delete mode 100755 tests/milestone-scripts/fake-gh.sh delete mode 100755 tests/milestone-scripts/run.sh delete mode 100644 tests/milestone-scripts/scenarios/audit-healthy/args.env delete mode 100644 tests/milestone-scripts/scenarios/audit-healthy/expected/exit delete mode 100644 tests/milestone-scripts/scenarios/audit-healthy/expected/journal.txt delete mode 100644 tests/milestone-scripts/scenarios/audit-healthy/expected/stdout.txt delete mode 100644 tests/milestone-scripts/scenarios/audit-healthy/fixture.json delete mode 100644 tests/milestone-scripts/scenarios/audit-master-inference/args.env delete mode 100644 tests/milestone-scripts/scenarios/audit-master-inference/expected/exit delete mode 100644 tests/milestone-scripts/scenarios/audit-master-inference/expected/journal.txt delete mode 100644 tests/milestone-scripts/scenarios/audit-master-inference/expected/stdout.txt delete mode 100644 tests/milestone-scripts/scenarios/audit-master-inference/fixture.json delete mode 100644 tests/milestone-scripts/scenarios/audit-missing-next/args.env delete mode 100644 tests/milestone-scripts/scenarios/audit-missing-next/expected/exit delete mode 100644 tests/milestone-scripts/scenarios/audit-missing-next/expected/journal.txt delete mode 100644 tests/milestone-scripts/scenarios/audit-missing-next/expected/stdout.txt delete mode 100644 tests/milestone-scripts/scenarios/audit-missing-next/fixture.json delete mode 100644 tests/milestone-scripts/scenarios/audit-missing-upcoming/args.env delete mode 100644 tests/milestone-scripts/scenarios/audit-missing-upcoming/expected/exit delete mode 100644 tests/milestone-scripts/scenarios/audit-missing-upcoming/expected/journal.txt delete mode 100644 tests/milestone-scripts/scenarios/audit-missing-upcoming/expected/stdout.txt delete mode 100644 tests/milestone-scripts/scenarios/audit-missing-upcoming/fixture.json delete mode 100644 tests/milestone-scripts/scenarios/audit-no-stable/args.env delete mode 100644 tests/milestone-scripts/scenarios/audit-no-stable/expected/exit delete mode 100644 tests/milestone-scripts/scenarios/audit-no-stable/expected/journal.txt delete mode 100644 tests/milestone-scripts/scenarios/audit-no-stable/expected/stdout.txt delete mode 100644 tests/milestone-scripts/scenarios/audit-no-stable/fixture.json delete mode 100644 tests/milestone-scripts/scenarios/audit-orphan/args.env delete mode 100644 tests/milestone-scripts/scenarios/audit-orphan/expected/exit delete mode 100644 tests/milestone-scripts/scenarios/audit-orphan/expected/journal.txt delete mode 100644 tests/milestone-scripts/scenarios/audit-orphan/expected/stdout.txt delete mode 100644 tests/milestone-scripts/scenarios/audit-orphan/fixture.json delete mode 100644 tests/milestone-scripts/scenarios/audit-released-open/args.env delete mode 100644 tests/milestone-scripts/scenarios/audit-released-open/expected/exit delete mode 100644 tests/milestone-scripts/scenarios/audit-released-open/expected/journal.txt delete mode 100644 tests/milestone-scripts/scenarios/audit-released-open/expected/stdout.txt delete mode 100644 tests/milestone-scripts/scenarios/audit-released-open/fixture.json delete mode 100644 tests/milestone-scripts/scenarios/audit-upcoming-no-due/args.env delete mode 100644 tests/milestone-scripts/scenarios/audit-upcoming-no-due/expected/exit delete mode 100644 tests/milestone-scripts/scenarios/audit-upcoming-no-due/expected/journal.txt delete mode 100644 tests/milestone-scripts/scenarios/audit-upcoming-no-due/expected/stdout.txt delete mode 100644 tests/milestone-scripts/scenarios/audit-upcoming-no-due/fixture.json delete mode 100644 tests/milestone-scripts/scenarios/bad-due-date/args.env delete mode 100644 tests/milestone-scripts/scenarios/bad-due-date/expected/exit delete mode 100644 tests/milestone-scripts/scenarios/bad-due-date/expected/journal.txt delete mode 100644 tests/milestone-scripts/scenarios/bad-due-date/expected/stdout.txt delete mode 100644 tests/milestone-scripts/scenarios/bad-due-date/fixture.json delete mode 100644 tests/milestone-scripts/scenarios/due-date-existing/args.env delete mode 100644 tests/milestone-scripts/scenarios/due-date-existing/expected/exit delete mode 100644 tests/milestone-scripts/scenarios/due-date-existing/expected/journal.txt delete mode 100644 tests/milestone-scripts/scenarios/due-date-existing/expected/stdout.txt delete mode 100644 tests/milestone-scripts/scenarios/due-date-existing/fixture.json delete mode 100644 tests/milestone-scripts/scenarios/due-date/args.env delete mode 100644 tests/milestone-scripts/scenarios/due-date/expected/exit delete mode 100644 tests/milestone-scripts/scenarios/due-date/expected/journal.txt delete mode 100644 tests/milestone-scripts/scenarios/due-date/expected/stdout.txt delete mode 100644 tests/milestone-scripts/scenarios/due-date/fixture.json delete mode 100644 tests/milestone-scripts/scenarios/existing-upcoming/args.env delete mode 100644 tests/milestone-scripts/scenarios/existing-upcoming/expected/exit delete mode 100644 tests/milestone-scripts/scenarios/existing-upcoming/expected/journal.txt delete mode 100644 tests/milestone-scripts/scenarios/existing-upcoming/expected/stdout.txt delete mode 100644 tests/milestone-scripts/scenarios/existing-upcoming/fixture.json delete mode 100644 tests/milestone-scripts/scenarios/first-beta-idempotent/args.env delete mode 100644 tests/milestone-scripts/scenarios/first-beta-idempotent/expected/exit delete mode 100644 tests/milestone-scripts/scenarios/first-beta-idempotent/expected/journal.txt delete mode 100644 tests/milestone-scripts/scenarios/first-beta-idempotent/expected/stdout.txt delete mode 100644 tests/milestone-scripts/scenarios/first-beta-idempotent/fixture.json delete mode 100644 tests/milestone-scripts/scenarios/first-beta/args.env delete mode 100644 tests/milestone-scripts/scenarios/first-beta/expected/exit delete mode 100644 tests/milestone-scripts/scenarios/first-beta/expected/journal.txt delete mode 100644 tests/milestone-scripts/scenarios/first-beta/expected/stdout.txt delete mode 100644 tests/milestone-scripts/scenarios/first-beta/fixture.json delete mode 100644 tests/milestone-scripts/scenarios/first-stable-altname/args.env delete mode 100644 tests/milestone-scripts/scenarios/first-stable-altname/expected/exit delete mode 100644 tests/milestone-scripts/scenarios/first-stable-altname/expected/journal.txt delete mode 100644 tests/milestone-scripts/scenarios/first-stable-altname/expected/stdout.txt delete mode 100644 tests/milestone-scripts/scenarios/first-stable-altname/fixture.json delete mode 100644 tests/milestone-scripts/scenarios/issue-pagination/args.env delete mode 100644 tests/milestone-scripts/scenarios/issue-pagination/expected/exit delete mode 100644 tests/milestone-scripts/scenarios/issue-pagination/expected/journal.txt delete mode 100644 tests/milestone-scripts/scenarios/issue-pagination/expected/stdout.txt delete mode 100644 tests/milestone-scripts/scenarios/issue-pagination/fixture.json delete mode 100644 tests/milestone-scripts/scenarios/missing-next/args.env delete mode 100644 tests/milestone-scripts/scenarios/missing-next/expected/exit delete mode 100644 tests/milestone-scripts/scenarios/missing-next/expected/journal.txt delete mode 100644 tests/milestone-scripts/scenarios/missing-next/expected/stdout.txt delete mode 100644 tests/milestone-scripts/scenarios/missing-next/fixture.json delete mode 100644 tests/milestone-scripts/scenarios/paginated-lookup/args.env delete mode 100644 tests/milestone-scripts/scenarios/paginated-lookup/expected/exit delete mode 100644 tests/milestone-scripts/scenarios/paginated-lookup/expected/journal.txt delete mode 100644 tests/milestone-scripts/scenarios/paginated-lookup/expected/stdout.txt delete mode 100644 tests/milestone-scripts/scenarios/paginated-lookup/fixture.json delete mode 100644 tests/milestone-scripts/scenarios/patch-release/args.env delete mode 100644 tests/milestone-scripts/scenarios/patch-release/expected/exit delete mode 100644 tests/milestone-scripts/scenarios/patch-release/expected/journal.txt delete mode 100644 tests/milestone-scripts/scenarios/patch-release/expected/stdout.txt delete mode 100644 tests/milestone-scripts/scenarios/patch-release/fixture.json delete mode 100644 tests/milestone-scripts/scenarios/prerelease-noop/args.env delete mode 100644 tests/milestone-scripts/scenarios/prerelease-noop/expected/exit delete mode 100644 tests/milestone-scripts/scenarios/prerelease-noop/expected/journal.txt delete mode 100644 tests/milestone-scripts/scenarios/prerelease-noop/expected/stdout.txt delete mode 100644 tests/milestone-scripts/scenarios/prerelease-noop/fixture.json delete mode 100755 tests/milestone-scripts/unit.sh create mode 100755 tools/release/bin/console create mode 100644 tools/release/src/Command/MilestonesAuditCommand.php create mode 100644 tools/release/src/Command/MilestonesUpdateCommand.php create mode 100644 tools/release/src/Command/RepoTagCommand.php create mode 100644 tools/release/src/GitHub/FakeGitHubApi.php create mode 100644 tools/release/src/GitHub/GitHubApi.php create mode 100644 tools/release/src/GitHub/KnpGitHubApi.php create mode 100644 tools/release/src/GitHub/Milestone.php create mode 100644 tools/release/src/MilestoneAuditor.php create mode 100644 tools/release/src/MilestoneUpdater.php create mode 100644 tools/release/src/ReleaseConfig.php create mode 100644 tools/release/src/RepoTagger.php create mode 100644 tools/release/src/TagPolicy.php create mode 100644 tools/release/src/TagResult.php create mode 100644 tools/release/tests/MilestoneAuditorTest.php create mode 100644 tools/release/tests/MilestoneUpdaterTest.php create mode 100644 tools/release/tests/ReleaseConfigTest.php create mode 100644 tools/release/tests/RepoTaggerTest.php create mode 100644 tools/release/tests/TagPolicyTest.php diff --git a/.github/scripts/README.md b/.github/scripts/README.md index fdd682947ae..c73ccba9b82 100644 --- a/.github/scripts/README.md +++ b/.github/scripts/README.md @@ -59,8 +59,10 @@ bash "$SCRIPTS/package.sh" /tmp/nextcloud "$VERSION" ./releases | `generate-metadata.sh` | Generate migration metadata (NC30+) | | `package.sh` | Set permissions, create tar.bz2 + zip, generate checksums | | `update-updater-server.sh` | Create a PR to the updater server with release config and tests | -| `update-milestones.sh` | Close/create milestones and move issues across all release repos | -| `audit-milestones.sh` | Check milestone consistency: orphans, missing milestones, naming issues | + +> Milestone management and repository tagging have moved to the unit-tested PHP +> package in [`tools/release/`](../../tools/release/README.md) +> (`milestones:update`, `milestones:audit`, `repo:tag`). ## Updater server @@ -88,43 +90,11 @@ bash .github/scripts/update-updater-server.sh v33.0.6 "$BZ2_SIG" "$ZIP_SIG" --re The workflow (`release-updater.yml`) can also be triggered manually from the Actions UI with a dry-run option for testing. -## Milestone management - -Update milestones after a stable release. For `v33.0.4` this will: - -1. Close `Nextcloud 33.0.4` across all repos -2. Move open issues to `Nextcloud 33.0.5` (should already exist from previous release) -3. Create `Nextcloud 33.0.6` so two open patch milestones always exist (33.0.5 + 33.0.6) - -```bash -# Dry run first -bash .github/scripts/update-milestones.sh v33.0.4 stable33.json tag-only.json --dry-run - -# Apply with due dates for the two open patch milestones. -# --next-due -> the next patch (33.0.5) -# --upcoming-due -> the one after (33.0.6) -# Each date is applied whether the milestone is created now or already exists, -# so re-running with the same dates is a no-op and fixes any stale dates. -bash .github/scripts/update-milestones.sh v33.0.4 stable33.json tag-only.json \ - --next-due 2026-07-02 --upcoming-due 2026-08-27 - -# Apply without due dates (can be set later manually) -bash .github/scripts/update-milestones.sh v33.0.4 stable33.json tag-only.json -``` - -Create the next major milestone on first beta. The first beta of major N opens -development of N+1, so `v34.0.0beta1` creates `Nextcloud 35` (the `Nextcloud 34` -milestone already exists from the previous cycle): +## Milestone management & tagging -```bash -bash .github/scripts/update-milestones.sh v34.0.0beta1 master.json tag-only.json -``` - -Audit milestone consistency (detects orphans, missing milestones, naming issues): - -```bash -bash .github/scripts/audit-milestones.sh stable33.json tag-only.json -``` +Moved to the PHP package - see [`tools/release/`](../../tools/release/README.md), +which documents the full release auto-logic (when milestones are created/closed, +when tags are created, which branch is used). The audit determines expected state from the latest stable release tags on `nextcloud-releases/server`. It exits with code 1 if issues are found. diff --git a/.github/scripts/audit-milestones.sh b/.github/scripts/audit-milestones.sh deleted file mode 100755 index 6322c2ab4e8..00000000000 --- a/.github/scripts/audit-milestones.sh +++ /dev/null @@ -1,243 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors -# SPDX-License-Identifier: MIT -# -# Audit milestone consistency across all release repositories. -# Reports orphaned milestones (should be closed), missing milestones, -# naming issues, and missing due dates. -# -# Usage: -# audit-milestones.sh -# -# Examples: -# audit-milestones.sh stable33.json tag-only.json -# -# The script determines expected state from the latest stable release tags -# on nextcloud-releases/server. It only checks milestones for the major -# version that corresponds to the given config file. -# -# Exit codes: -# 0 - no issues found -# 1 - issues found (details in output and step summary) - -set -euo pipefail - -# Allow tests to inject a mock gh; defaults to the real CLI. -GH="${GH:-gh}" - -CONFIG="${1:?Usage: audit-milestones.sh }" -TAG_ONLY="${2:?Missing tag-only.json path}" - -# Determine which major version this config covers -CONFIG_BASENAME=$(basename "$CONFIG" .json) -if [[ "$CONFIG_BASENAME" == "master" ]]; then - # For master.json, find the highest major version tag - MAJOR=$("$GH" api repos/nextcloud-releases/server/git/refs/tags \ - --paginate --jq '.[].ref | sub("refs/tags/v"; "")' \ - | grep -E '^[0-9]+\.' | cut -d. -f1 | sort -n | tail -1) - # Next major (the one being developed on master) - MAJOR=$((MAJOR + 1)) -else - MAJOR="${CONFIG_BASENAME#stable}" -fi - -# Build repo list (same logic as update-milestones.sh) -REPOS=$( - jq -r ' - if type == "array" and (.[0] | type) == "object" then - .[].repo - elif type == "array" then - .[] - else - empty - end - ' "$CONFIG" "$TAG_ONLY" | sort -u -) - -REPO_COUNT=$(echo "$REPOS" | wc -l) - -# Find the latest stable release for this major version -LATEST_STABLE=$("$GH" api repos/nextcloud-releases/server/git/refs/tags \ - --paginate --jq '.[].ref | sub("refs/tags/"; "")' \ - | grep -E "^v${MAJOR}\.[0-9]+\.[0-9]+$" | sort -V | tail -1 || true) - -if [[ -z "$LATEST_STABLE" ]]; then - echo "No stable release found for major version ${MAJOR}. Skipping audit." - exit 0 -fi - -LATEST_VERSION="${LATEST_STABLE#v}" -LATEST_PATCH=$(echo "$LATEST_VERSION" | cut -d. -f3) -LATEST_MINOR=$(echo "$LATEST_VERSION" | cut -d. -f2) - -# Expected state: the released milestone should be closed, and two open -# patch milestones should exist (next and upcoming). -NEXT_PATCH=$((LATEST_PATCH + 1)) -UPCOMING_PATCH=$((LATEST_PATCH + 2)) - -RELEASED_MILESTONE="Nextcloud ${MAJOR}.${LATEST_MINOR}.${LATEST_PATCH}" -NEXT_MILESTONE="Nextcloud ${MAJOR}.${LATEST_MINOR}.${NEXT_PATCH}" -UPCOMING_MILESTONE="Nextcloud ${MAJOR}.${LATEST_MINOR}.${UPCOMING_PATCH}" - -# For .0.0, also check the short-form "Nextcloud XX" name -RELEASED_MILESTONE_ALT="" -if [[ "$LATEST_PATCH" -eq 0 && "$LATEST_MINOR" -eq 0 ]]; then - RELEASED_MILESTONE_ALT="Nextcloud ${MAJOR}" -fi - -echo "=== Milestone audit for Nextcloud ${MAJOR} ===" -echo " Latest stable release: ${LATEST_STABLE}" -echo " Expected closed: ${RELEASED_MILESTONE}${RELEASED_MILESTONE_ALT:+ (or ${RELEASED_MILESTONE_ALT})}" -echo " Expected open: ${NEXT_MILESTONE}, ${UPCOMING_MILESTONE}" -echo " Repos to check: ${REPO_COUNT}" -echo "" - -# Summary tracking -SUMMARY_FILE=$(mktemp) -ISSUES_FILE=$(mktemp) -echo "| Repository | Status | Details |" > "$SUMMARY_FILE" -echo "|---|---|---|" >> "$SUMMARY_FILE" - -TOTAL_OK=0 -TOTAL_WARN=0 - -warn_issue() { - local repo="$1" msg="$2" - echo "::warning::${repo}: ${msg}" - echo "- **${repo}**: ${msg}" >> "$ISSUES_FILE" -} - -while IFS= read -r repo; do - echo "Checking ${repo}..." - repo_issues=() - - # Fetch all milestones for this repo (only Nextcloud XX ones). - # --paginate: busy repos have >100 milestones spanning several pages. - all_milestones=$("$GH" api "repos/${repo}/milestones?state=all&per_page=100" --paginate \ - --jq '.[] | select(.title | startswith("Nextcloud '"${MAJOR}"'")) | "\(.title)\t\(.state)\t\(.open_issues)\t\(.due_on // "none")"' \ - 2>/dev/null || true) - - if [[ -z "$all_milestones" ]]; then - repo_issues+=("No milestones found for Nextcloud ${MAJOR}") - warn_issue "$repo" "No milestones found for Nextcloud ${MAJOR}" - else - # Check: released milestone should be closed - while IFS=$'\t' read -r title state open_issues due_on; do - if [[ "$title" == "$RELEASED_MILESTONE" ]] || [[ -n "$RELEASED_MILESTONE_ALT" && "$title" == "$RELEASED_MILESTONE_ALT" ]]; then - if [[ "$state" == "open" ]]; then - repo_issues+=("'${title}' still open (${open_issues} open issues) - should be closed") - warn_issue "$repo" "'${title}' still open (${open_issues} open issues) - should be closed" - fi - fi - done <<< "$all_milestones" - - # Check: next milestone should exist and be open - next_found=false - while IFS=$'\t' read -r title state open_issues due_on; do - if [[ "$title" == "$NEXT_MILESTONE" ]]; then - next_found=true - if [[ "$state" != "open" ]]; then - repo_issues+=("'${title}' is ${state} - should be open") - warn_issue "$repo" "'${title}' is ${state} - should be open" - fi - fi - done <<< "$all_milestones" - if ! $next_found; then - repo_issues+=("Missing milestone '${NEXT_MILESTONE}'") - warn_issue "$repo" "Missing milestone '${NEXT_MILESTONE}'" - fi - - # Check: upcoming milestone should exist and be open - upcoming_found=false - while IFS=$'\t' read -r title state open_issues due_on; do - if [[ "$title" == "$UPCOMING_MILESTONE" ]]; then - upcoming_found=true - if [[ "$state" != "open" ]]; then - repo_issues+=("'${title}' is ${state} - should be open") - warn_issue "$repo" "'${title}' is ${state} - should be open" - fi - if [[ "$due_on" == "none" ]]; then - repo_issues+=("'${title}' has no due date") - warn_issue "$repo" "'${title}' has no due date" - fi - fi - done <<< "$all_milestones" - if ! $upcoming_found; then - repo_issues+=("Missing milestone '${UPCOMING_MILESTONE}'") - warn_issue "$repo" "Missing milestone '${UPCOMING_MILESTONE}'" - fi - - # Check: orphans - any open milestones for patches older than the released one - while IFS=$'\t' read -r title state open_issues due_on; do - if [[ "$state" != "open" ]]; then - continue - fi - # Extract patch number from title like "Nextcloud 33.0.4" - if [[ "$title" =~ ^Nextcloud\ ${MAJOR}\.${LATEST_MINOR}\.([0-9]+)$ ]]; then - ms_patch="${BASH_REMATCH[1]}" - if [[ "$ms_patch" -le "$LATEST_PATCH" ]]; then - repo_issues+=("Orphan: '${title}' still open (${open_issues} open issues) - version already released") - warn_issue "$repo" "Orphan: '${title}' still open (${open_issues} open issues) - version already released" - fi - fi - # Check short-form "Nextcloud XX" if the .0.0 is already released - if [[ "$LATEST_PATCH" -ge 0 && "$title" == "Nextcloud ${MAJOR}" && "$state" == "open" ]]; then - # Only flag if it's not the alt name we already checked above - if [[ -z "$RELEASED_MILESTONE_ALT" || "$LATEST_PATCH" -gt 0 ]]; then - repo_issues+=("Orphan: '${title}' still open (${open_issues} open issues) - major already released") - warn_issue "$repo" "Orphan: '${title}' still open (${open_issues} open issues) - major already released" - fi - fi - done <<< "$all_milestones" - - # Check: naming issues - milestones that look like Nextcloud XX but with - # wrong casing or spacing - while IFS=$'\t' read -r title state open_issues due_on; do - if [[ "$title" =~ ^[Nn]extcloud\ *${MAJOR} ]] && [[ ! "$title" =~ ^Nextcloud\ ${MAJOR} ]]; then - repo_issues+=("Naming issue: '${title}' - expected 'Nextcloud ${MAJOR}...'") - warn_issue "$repo" "Naming issue: '${title}' - expected 'Nextcloud ${MAJOR}...'" - fi - done <<< "$all_milestones" - fi - - # Record result - if [[ ${#repo_issues[@]} -eq 0 ]]; then - echo "| ${repo} | :white_check_mark: | OK |" >> "$SUMMARY_FILE" - TOTAL_OK=$((TOTAL_OK + 1)) - else - detail=$(IFS='; '; echo "${repo_issues[*]}") - echo "| ${repo} | :warning: | ${detail} |" >> "$SUMMARY_FILE" - TOTAL_WARN=$((TOTAL_WARN + 1)) - fi -done <<< "$REPOS" - -# Print summary -echo "" -echo "=== Audit summary ===" -echo " OK: ${TOTAL_OK} repos" -echo " Issues: ${TOTAL_WARN} repos" - -if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then - { - echo "## Milestone audit for Nextcloud ${MAJOR}" - echo "" - echo "Latest stable release: \`${LATEST_STABLE}\`" - echo "Expected open milestones: \`${NEXT_MILESTONE}\`, \`${UPCOMING_MILESTONE}\`" - echo "" - if [[ -s "$ISSUES_FILE" ]]; then - echo "### Issues found" - echo "" - cat "$ISSUES_FILE" - echo "" - fi - cat "$SUMMARY_FILE" - echo "" - echo "**${TOTAL_OK}** OK, **${TOTAL_WARN}** with issues" - } >> "$GITHUB_STEP_SUMMARY" -fi - -rm -f "$SUMMARY_FILE" "$ISSUES_FILE" - -if [[ "$TOTAL_WARN" -gt 0 ]]; then - exit 1 -fi diff --git a/.github/scripts/tag-repo.sh b/.github/scripts/tag-repo.sh deleted file mode 100755 index 8d1e849ae5d..00000000000 --- a/.github/scripts/tag-repo.sh +++ /dev/null @@ -1,151 +0,0 @@ -#!/bin/bash -# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors -# SPDX-License-Identifier: MIT -# -# Tag a single repository at a branch HEAD. -# Uses gh release create+delete (fast), falls back to git clone+tag+push. -# -# Usage: tag-repo.sh [--force] -# -# Environment: -# GH_TOKEN — GitHub token for gh CLI (required for gh mode) -# GIT_SSH_COMMAND — optional SSH command for git fallback -# -# Exit codes: 0 = tagged or skipped, 1 = failed -# Stdout: one of OK|SKIPPED|FAILED followed by details - -REPO="${1:?Usage: tag-repo.sh [--force]}" -BRANCH="${2:?Missing branch}" -TAG="${3:?Missing tag}" -FORCE="${4:-}" - -# Server repos manage their own releases — never force-retag them -SERVER_REPOS="nextcloud/server nextcloud-releases/server" - -# Check if gh CLI is available -GH_AVAILABLE=false -if command -v gh &>/dev/null; then - GH_AVAILABLE=true -fi - -# Check if tag already exists (works with both gh and git) -tag_exists_gh() { - gh api "repos/$REPO/git/ref/tags/$TAG" &>/dev/null -} - -tag_exists_git() { - git ls-remote --tags "https://github.com/$REPO.git" "$TAG" 2>/dev/null | grep -q "$TAG" -} - -# Check tag existence and handle skip/force logic -# Returns: 0 = proceed with tagging, 1 = skip (already tagged), 2 = error -check_existing_tag() { - local exists=false - - if $GH_AVAILABLE; then - tag_exists_gh && exists=true - else - tag_exists_git && exists=true - fi - - if ! $exists; then - return 0 - fi - - # Tag exists — check if this is a server repo (never force) - for server_repo in $SERVER_REPOS; do - if [ "$REPO" = "$server_repo" ]; then - echo "SKIPPED already tagged (server repo, never force)" - return 1 - fi - done - - if [ "$FORCE" = "--force" ]; then - echo "tag exists, force-replacing" >&2 - return 0 - fi - - echo "SKIPPED already tagged" - return 1 -} - -tag_via_gh() { - # Force: delete existing release + tag first - if [ "$FORCE" = "--force" ]; then - gh release delete "$TAG" --repo "$REPO" --yes &>/dev/null || true - gh api -X DELETE "repos/$REPO/git/refs/tags/$TAG" &>/dev/null || true - fi - - # Create release (creates the tag on the target branch) - local output - if ! output=$(gh release create "$TAG" \ - --repo "$REPO" \ - --target "$BRANCH" \ - --title "$TAG" \ - --notes "" \ - 2>&1); then - echo "gh release create failed: $output" >&2 - return 1 - fi - - # Give GitHub a moment to propagate - sleep 1 - - # Delete the release, keep the tag - gh release delete "$TAG" --repo "$REPO" --yes &>/dev/null || true - - echo "OK tagged via gh" - return 0 -} - -tag_via_git() { - local tmpdir - tmpdir=$(mktemp -d) - trap 'rm -rf "$tmpdir"' EXIT - - if ! git clone --depth 1 --branch "$BRANCH" "https://github.com/$REPO.git" "$tmpdir/repo" -q 2>/dev/null; then - # Try SSH if HTTPS fails - if ! git clone --depth 1 --branch "$BRANCH" "git@github.com:$REPO.git" "$tmpdir/repo" -q 2>/dev/null; then - echo "FAILED clone failed" - return 1 - fi - fi - - cd "$tmpdir/repo" || return 1 - - # Force: delete existing tag on remote - if [ "$FORCE" = "--force" ]; then - git push origin ":refs/tags/$TAG" 2>/dev/null || true - fi - - git tag "$TAG" - if git push origin "$TAG" 2>/dev/null; then - echo "OK tagged via git" - return 0 - else - echo "FAILED push failed" - return 1 - fi -} - -# Check if tag already exists -check_existing_tag -rc=$? -if [ $rc -eq 1 ]; then - exit 0 -fi - -# Try gh first, fall back to git -if $GH_AVAILABLE; then - if tag_via_gh; then - exit 0 - fi - echo "gh failed, falling back to git clone" >&2 -fi - -if tag_via_git; then - exit 0 -fi - -echo "FAILED" -exit 1 diff --git a/.github/scripts/update-milestones.sh b/.github/scripts/update-milestones.sh deleted file mode 100755 index f651f117622..00000000000 --- a/.github/scripts/update-milestones.sh +++ /dev/null @@ -1,377 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors -# SPDX-License-Identifier: MIT -# -# Manage milestones across all release repositories after a stable release -# or first beta of a new major version. -# -# Usage: -# update-milestones.sh [options] -# -# Options: -# --dry-run Preview changes without applying them -# --next-due YYYY-MM-DD Due date for the next patch milestone (e.g. 34.0.1) -# --upcoming-due YYYY-MM-DD Due date for the second-to-next patch (e.g. 34.0.2) -# -# A run keeps two open patch milestones (next + upcoming); --next-due and -# --upcoming-due set their due dates. The date is applied whether the milestone -# is created by this run or already exists, so re-running corrects stale dates. -# -# Examples: -# update-milestones.sh v33.0.4 stable33.json tag-only.json -# update-milestones.sh v33.0.4 stable33.json tag-only.json --next-due 2026-07-02 --upcoming-due 2026-08-27 -# update-milestones.sh v35.0.0beta1 master.json tag-only.json -# update-milestones.sh v33.0.4 stable33.json tag-only.json --dry-run - -set -euo pipefail - -# Allow tests to inject a mock gh; defaults to the real CLI. -GH="${GH:-gh}" - -# Two modes: -# - Stable release (e.g. v33.0.4): close released milestone, create next patch, move open issues -# - First beta (e.g. v35.0.0beta1): only create the new major milestone, no close/move - -TAG="${1:?Usage: update-milestones.sh [options]}" -CONFIG="${2:?Missing config.json path}" -TAG_ONLY="${3:?Missing tag-only.json path}" -shift 3 - -DRY_RUN=false -NEXT_DUE_DATE="" -UPCOMING_DUE_DATE="" -while [[ $# -gt 0 ]]; do - case "$1" in - --dry-run) DRY_RUN=true ;; - --next-due) NEXT_DUE_DATE="${2:?--next-due requires a YYYY-MM-DD value}"; shift ;; - --upcoming-due) UPCOMING_DUE_DATE="${2:?--upcoming-due requires a YYYY-MM-DD value}"; shift ;; - *) echo "Unknown option: $1"; exit 1 ;; - esac - shift -done - -# Validate YYYY-MM-DD format (empty is allowed). Runs in the main shell so an -# invalid date aborts the script immediately. -validate_date() { - local date="$1" label="$2" - if [[ -n "$date" && ! "$date" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then - echo "::error::Invalid ${label} '${date}', expected YYYY-MM-DD" - exit 1 - fi -} -validate_date "$NEXT_DUE_DATE" "--next-due" -validate_date "$UPCOMING_DUE_DATE" "--upcoming-due" - -# Convert to the ISO 8601 form the GitHub API expects (empty stays empty). -NEXT_DUE_ON="" -UPCOMING_DUE_ON="" -[[ -n "$NEXT_DUE_DATE" ]] && NEXT_DUE_ON="${NEXT_DUE_DATE}T00:00:00Z" -[[ -n "$UPCOMING_DUE_DATE" ]] && UPCOMING_DUE_ON="${UPCOMING_DUE_DATE}T00:00:00Z" - -# VERSION: full semver without leading "v"; MAJOR/MINOR: numeric components -# PATCH: third component with pre-release suffixes (alpha/beta/rc…) stripped -VERSION="${TAG#v}" -MAJOR=$(echo "$VERSION" | cut -d. -f1) -MINOR=$(echo "$VERSION" | cut -d. -f2) -PATCH=$(echo "$VERSION" | sed 's/\(alpha\|beta\|rc\).*//' | cut -d. -f3) - -IS_FIRST_BETA=false -if [[ "$TAG" =~ \.0\.0beta1$ ]]; then - IS_FIRST_BETA=true -fi - -IS_PRERELEASE=false -if [[ "$TAG" =~ (alpha|beta|rc) ]]; then - IS_PRERELEASE=true -fi - -# Build repo list from config + tag-only -# stableXX.json contains objects with .repo keys; tag-only.json has plain strings. -# The jq handles both formats so we can feed both files in a single pipeline. -REPOS=$( - jq -r ' - if type == "array" and (.[0] | type) == "object" then - .[].repo - elif type == "array" then - .[] - else - empty - end - ' "$CONFIG" "$TAG_ONLY" | sort -u -) - -REPO_COUNT=$(echo "$REPOS" | wc -l) - -# Summary tracking -SUMMARY_FILE=$(mktemp) -echo "| Repository | Closed | Created | Issues moved |" > "$SUMMARY_FILE" -echo "|---|---|---|---|" >> "$SUMMARY_FILE" - -TOTAL_CLOSED=0 -TOTAL_CREATED=0 -TOTAL_MOVED=0 - -log() { - echo "::notice::$*" -} - -warn() { - echo "::warning::$*" -} - -dry_run_prefix() { - if $DRY_RUN; then - echo "[dry-run] " - fi -} - -# Find a milestone by title in a repo, return its number (or empty) -find_milestone() { - local repo="$1" title="$2" - # --paginate: busy repos (e.g. nextcloud/server) have >100 milestones, so the - # target can be on a later page; without it the lookup silently misses them. - "$GH" api "repos/${repo}/milestones?state=all&per_page=100" --paginate \ - --jq ".[] | select(.title == \"${title}\") | .number" 2>/dev/null || true -} - -# Close a milestone -close_milestone() { - local repo="$1" number="$2" title="$3" - if $DRY_RUN; then - echo " $(dry_run_prefix)Would close milestone '${title}' (#${number})" - return 0 - fi - if "$GH" api "repos/${repo}/milestones/${number}" -X PATCH -f state=closed --silent 2>/dev/null; then - echo " Closed milestone '${title}' (#${number})" - return 0 - else - warn "Failed to close milestone '${title}' in ${repo}" - return 1 - fi -} - -# Create a milestone -create_milestone() { - local repo="$1" title="$2" due_on="${3:-}" - if $DRY_RUN; then - echo " $(dry_run_prefix)Would create milestone '${title}' (due: ${due_on:-none})" - return 0 - fi - local args=(-f "title=${title}") - if [[ -n "$due_on" ]]; then - args+=(-f "due_on=${due_on}") - fi - if "$GH" api "repos/${repo}/milestones" -X POST "${args[@]}" --silent 2>/dev/null; then - echo " Created milestone '${title}'" - return 0 - else - warn "Failed to create milestone '${title}' in ${repo}" - return 1 - fi -} - -# Set (or correct) the due date of an existing milestone. No-op when no date -# is given, so callers can pass an empty value unconditionally. -set_due() { - local repo="$1" number="$2" title="$3" due_on="$4" - [[ -z "$due_on" ]] && return 0 - if $DRY_RUN; then - echo " $(dry_run_prefix)Would set due date of '${title}' (#${number}) to ${due_on}" - return 0 - fi - if "$GH" api "repos/${repo}/milestones/${number}" -X PATCH -f "due_on=${due_on}" --silent 2>/dev/null; then - echo " Set due date of '${title}' to ${due_on}" - return 0 - else - warn "Failed to set due date of '${title}' in ${repo}" - return 1 - fi -} - -# Move all open issues from one milestone to another. -# Called BEFORE closing the source milestone so no issues get orphaned. -move_issues() { - local repo="$1" from_number="$2" to_number="$3" to_title="$4" - local moved=0 - local page=1 - - # First collect every open issue number, paginating in batches of 100. - # We gather the full list before moving any issue: moving an issue removes - # it from the source milestone, which would shift later pages and cause the - # page counter to skip issues (and could loop forever if a move failed). - local all_issues="" - while true; do - local issues - issues=$("$GH" api "repos/${repo}/issues?milestone=${from_number}&state=open&per_page=100&page=${page}" \ - --jq '.[].number' 2>/dev/null) || break - - if [[ -z "$issues" ]]; then - break - fi - - all_issues+="${issues}"$'\n' - page=$((page + 1)) - done - - # Now move each gathered issue. Progress goes to stderr; only the final - # count is written to stdout so the caller can capture it via $(move_issues ...). - while IFS= read -r issue_number; do - [[ -z "$issue_number" ]] && continue - if $DRY_RUN; then - echo " $(dry_run_prefix)Would move #${issue_number} → '${to_title}'" >&2 - else - if "$GH" api "repos/${repo}/issues/${issue_number}" -X PATCH \ - -F "milestone=${to_number}" --silent 2>/dev/null; then - echo " Moved #${issue_number} → '${to_title}'" >&2 - else - warn "Failed to move ${repo}#${issue_number}" >&2 - fi - fi - moved=$((moved + 1)) - done <<< "$all_issues" - - echo "$moved" -} - -# --- Main logic --- - -if $IS_FIRST_BETA; then - # First beta of major N opens development of the NEXT major (N+1). - # The "Nextcloud N" milestone already exists from the previous cycle's first - # beta, so here we create "Nextcloud N+1" (e.g. v34.0.0beta1 -> Nextcloud 35). - NEXT_MAJOR=$((MAJOR + 1)) - NEXT_MAJOR_MILESTONE="Nextcloud ${NEXT_MAJOR}" - echo "First beta detected (${TAG}). Creating milestone '${NEXT_MAJOR_MILESTONE}' across ${REPO_COUNT} repos." - - while IFS= read -r repo; do - echo "Processing ${repo}..." - existing=$(find_milestone "$repo" "$NEXT_MAJOR_MILESTONE") - repo_created="-" - if [[ -z "$existing" ]]; then - # Major milestones don't take a patch due date. - create_milestone "$repo" "$NEXT_MAJOR_MILESTONE" "" - repo_created="$NEXT_MAJOR_MILESTONE" - TOTAL_CREATED=$((TOTAL_CREATED + 1)) - else - echo " Milestone '${NEXT_MAJOR_MILESTONE}' already exists (#${existing})" - fi - echo "| ${repo} | - | ${repo_created} | - |" >> "$SUMMARY_FILE" - done <<< "$REPOS" - -elif ! $IS_PRERELEASE; then - # Stable release: close current, create next, move issues - NEXT_PATCH=$((PATCH + 1)) - - # Naming convention: initial major releases (v34.0.0) may use short form "Nextcloud 34", - # while patch releases always use full form "Nextcloud 34.0.1". Try both for .0.0. - if [[ "$PATCH" -eq 0 && "$MINOR" -eq 0 ]]; then - CURRENT_MILESTONES=("Nextcloud ${MAJOR}.${MINOR}.${PATCH}" "Nextcloud ${MAJOR}") - else - CURRENT_MILESTONES=("Nextcloud ${MAJOR}.${MINOR}.${PATCH}") - fi - NEXT_MILESTONE="Nextcloud ${MAJOR}.${MINOR}.${NEXT_PATCH}" - - # We always keep two open patch milestones. When closing 33.0.4: - # - 33.0.5 becomes the current open milestone (issues move here) - # - 33.0.6 is created as the next upcoming milestone - UPCOMING_PATCH=$((PATCH + 2)) - UPCOMING_MILESTONE="Nextcloud ${MAJOR}.${MINOR}.${UPCOMING_PATCH}" - - echo "Stable release detected (${TAG})." - echo " Close: ${CURRENT_MILESTONES[*]}" - echo " Move issues to: ${NEXT_MILESTONE}${NEXT_DUE_ON:+ (due: ${NEXT_DUE_DATE})}" - echo " Create: ${UPCOMING_MILESTONE}${UPCOMING_DUE_ON:+ (due: ${UPCOMING_DUE_DATE})}" - echo " Repos: ${REPO_COUNT}" - echo "" - - while IFS= read -r repo; do - echo "Processing ${repo}..." - repo_closed="-" - repo_created="-" - repo_moved=0 - - # Find and close current milestone (try each candidate name) - current_number="" - current_title="" - for title in "${CURRENT_MILESTONES[@]}"; do - current_number=$(find_milestone "$repo" "$title") - if [[ -n "$current_number" ]]; then - current_title="$title" - break - fi - done - - if [[ -n "$current_number" ]]; then - # Ensure the next milestone exists (should already from previous release, - # but create it if missing so issue moves don't fail). Either way, - # set its due date to --next-due if one was given. - next_number=$(find_milestone "$repo" "$NEXT_MILESTONE") - if [[ -z "$next_number" ]]; then - create_milestone "$repo" "$NEXT_MILESTONE" "$NEXT_DUE_ON" - next_number=$(find_milestone "$repo" "$NEXT_MILESTONE") - repo_created="$NEXT_MILESTONE" - TOTAL_CREATED=$((TOTAL_CREATED + 1)) - else - set_due "$repo" "$next_number" "$NEXT_MILESTONE" "$NEXT_DUE_ON" - fi - - # Move open issues before closing - if [[ -n "$next_number" ]]; then - repo_moved=$(move_issues "$repo" "$current_number" "$next_number" "$NEXT_MILESTONE") - TOTAL_MOVED=$((TOTAL_MOVED + repo_moved)) - fi - - # Close current milestone - close_milestone "$repo" "$current_number" "$current_title" - repo_closed="$current_title" - TOTAL_CLOSED=$((TOTAL_CLOSED + 1)) - - # Create the upcoming milestone (two ahead) so there are always - # two open patch milestones: the next release and the one after - upcoming_number=$(find_milestone "$repo" "$UPCOMING_MILESTONE") - if [[ -z "$upcoming_number" ]]; then - create_milestone "$repo" "$UPCOMING_MILESTONE" "$UPCOMING_DUE_ON" - if [[ "$repo_created" == "-" ]]; then - repo_created="$UPCOMING_MILESTONE" - else - repo_created="${repo_created}, ${UPCOMING_MILESTONE}" - fi - TOTAL_CREATED=$((TOTAL_CREATED + 1)) - else - echo " Milestone '${UPCOMING_MILESTONE}' already exists (#${upcoming_number})" - set_due "$repo" "$upcoming_number" "$UPCOMING_MILESTONE" "$UPCOMING_DUE_ON" - fi - else - echo " No milestone found for ${CURRENT_MILESTONES[*]}, skipping" - fi - - echo "| ${repo} | ${repo_closed} | ${repo_created} | ${repo_moved} |" >> "$SUMMARY_FILE" - done <<< "$REPOS" - -else - echo "Tag ${TAG} is a pre-release but not first beta. Nothing to do." - exit 0 -fi - -# Print summary -echo "" -echo "=== Summary ===" -echo " Milestones closed: ${TOTAL_CLOSED}" -echo " Milestones created: ${TOTAL_CREATED}" -echo " Issues moved: ${TOTAL_MOVED}" - -if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then - { - echo "## Milestone updates for ${TAG}" - echo "" - if $DRY_RUN; then - echo "> **Dry run** - no changes were made" - echo "" - fi - cat "$SUMMARY_FILE" - echo "" - echo "**Totals:** ${TOTAL_CLOSED} closed, ${TOTAL_CREATED} created, ${TOTAL_MOVED} issues moved" - } >> "$GITHUB_STEP_SUMMARY" -fi - -rm -f "$SUMMARY_FILE" diff --git a/.github/workflows/release-milestones.yml b/.github/workflows/release-milestones.yml index b2bbf27276f..b9fe0d4e73f 100644 --- a/.github/workflows/release-milestones.yml +++ b/.github/workflows/release-milestones.yml @@ -76,6 +76,16 @@ jobs: with: persist-credentials: false + - name: Set up PHP + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2.37.1 + with: + php-version: '8.3' + tools: composer + + - name: Install release-tools + run: composer install --no-interaction --no-progress --no-dev + working-directory: tools/release + # Alpha/beta releases use master.json (they branch from master). # Stable releases use stableXX.json matching their major version. - name: Determine config file @@ -114,7 +124,7 @@ jobs: if [[ -n "${{ inputs.upcoming_due }}" ]]; then ARGS+=("--upcoming-due" "${{ inputs.upcoming_due }}") fi - bash .github/scripts/update-milestones.sh "${ARGS[@]}" + php tools/release/bin/console milestones:update "${ARGS[@]}" # Audit runs after every update (and on audit_only) to verify # milestone consistency: orphans, missing milestones, naming issues. @@ -124,6 +134,6 @@ jobs: env: GH_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} run: | - bash .github/scripts/audit-milestones.sh \ + php tools/release/bin/console milestones:audit \ "${{ steps.config.outputs.config }}" \ "tag-only.json" || true diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index e9f5b5848a1..bba7651d444 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -47,6 +47,16 @@ jobs: with: persist-credentials: false + - name: Set up PHP + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2.37.1 + with: + php-version: '8.3' + tools: composer + + - name: Install release-tools + run: composer install --no-interaction --no-progress --no-dev + working-directory: tools/release + - name: Determine branch and config id: config run: | @@ -81,80 +91,19 @@ jobs: echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" echo "config=$CONFIG" >> "$GITHUB_OUTPUT" - echo "::notice::Tagging repos from $CONFIG + tag-only.json at $BRANCH HEAD → $TAG" + echo "::notice::Tagging repos from $CONFIG + tag-only.json at $BRANCH HEAD -> $TAG" - name: Tag all repositories env: GH_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} run: | - TAG="${{ env.RELEASE_TAG }}" - BRANCH="${{ steps.config.outputs.branch }}" - CONFIG="${{ steps.config.outputs.config }}" - FORCE_FLAG="" + ARGS=( + "${{ env.RELEASE_TAG }}" + "${{ steps.config.outputs.branch }}" + "${{ steps.config.outputs.config }}" + "tag-only.json" + ) if [ "${{ inputs.force }}" = "true" ]; then - FORCE_FLAG="--force" - fi - - # Merge app repos from build config + non-build repos from tag-only.json - REPOS=$(jq -r '.[].repo' "$CONFIG") - if [ -f "tag-only.json" ]; then - REPOS=$(echo "$REPOS"; jq -r '.[]' "tag-only.json") - fi - REPOS=$(echo "$REPOS" | sort -u) - - SUCCESS=0 - SKIPPED=0 - FAILED=0 - - { - echo "## Tag results: $TAG" - echo "" - echo "| Repository | Branch | Status |" - echo "|---|---|---|" - } >> "$GITHUB_STEP_SUMMARY" - - while read -r repo; do - [ -z "$repo" ] && continue - - # Resolve branch: try stable branch, fall back to repo default - REPO_BRANCH="$BRANCH" - if ! gh api "repos/$repo/git/ref/heads/$REPO_BRANCH" &>/dev/null; then - REPO_BRANCH=$(gh api "repos/$repo" --jq '.default_branch' 2>/dev/null) || REPO_BRANCH="" - fi - - if [ -z "$REPO_BRANCH" ]; then - echo "$repo: no branch found" - echo "| \`$repo\` | - | no branch found |" >> "$GITHUB_STEP_SUMMARY" - FAILED=$((FAILED + 1)) - continue - fi - - echo -n "$repo@$REPO_BRANCH → $TAG ... " - RESULT=$(bash .github/scripts/tag-repo.sh "$repo" "$REPO_BRANCH" "$TAG" $FORCE_FLAG) - echo "$RESULT" - - case "$RESULT" in - OK*) - echo "| \`$repo\` | $REPO_BRANCH | tagged |" >> "$GITHUB_STEP_SUMMARY" - SUCCESS=$((SUCCESS + 1)) - ;; - SKIPPED*) - echo "| \`$repo\` | $REPO_BRANCH | already tagged |" >> "$GITHUB_STEP_SUMMARY" - SKIPPED=$((SKIPPED + 1)) - ;; - *) - echo "| \`$repo\` | $REPO_BRANCH | **failed** |" >> "$GITHUB_STEP_SUMMARY" - FAILED=$((FAILED + 1)) - ;; - esac - done <<< "$REPOS" - - { - echo "" - echo "**Tagged: $SUCCESS | Skipped: $SKIPPED | Failed: $FAILED**" - } >> "$GITHUB_STEP_SUMMARY" - - if [ "$FAILED" -gt 0 ]; then - echo "::error::$FAILED repos failed to tag" - exit 1 + ARGS+=("--force") fi + php tools/release/bin/console repo:tag "${ARGS[@]}" diff --git a/.github/workflows/test-milestone-scripts.yml b/.github/workflows/test-milestone-scripts.yml deleted file mode 100644 index cb4b0522d71..00000000000 --- a/.github/workflows/test-milestone-scripts.yml +++ /dev/null @@ -1,44 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors -# SPDX-License-Identifier: MIT - -name: Test milestone scripts - -on: - push: - branches: - - main - paths: - - '.github/scripts/update-milestones.sh' - - '.github/scripts/audit-milestones.sh' - - 'tests/milestone-scripts/**' - pull_request: - paths: - - '.github/scripts/update-milestones.sh' - - '.github/scripts/audit-milestones.sh' - - 'tests/milestone-scripts/**' - -permissions: - contents: read - -jobs: - test: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - - name: Shellcheck - run: | - shellcheck \ - .github/scripts/update-milestones.sh \ - .github/scripts/audit-milestones.sh \ - tests/milestone-scripts/run.sh \ - tests/milestone-scripts/unit.sh \ - tests/milestone-scripts/fake-gh.sh - - - name: Run snapshot tests - run: bash tests/milestone-scripts/run.sh - - - name: Run unit tests - run: bash tests/milestone-scripts/unit.sh diff --git a/.github/workflows/test-release-tools.yml b/.github/workflows/test-release-tools.yml index 9cbe98aca2e..3ecc31f35b9 100644 --- a/.github/workflows/test-release-tools.yml +++ b/.github/workflows/test-release-tools.yml @@ -7,11 +7,7 @@ on: push: branches: - main - paths: - - 'tools/release/**' pull_request: - paths: - - 'tools/release/**' permissions: contents: read diff --git a/tests/milestone-scripts/configs/master.json b/tests/milestone-scripts/configs/master.json deleted file mode 100644 index aaa41f13897..00000000000 --- a/tests/milestone-scripts/configs/master.json +++ /dev/null @@ -1,3 +0,0 @@ -[ - { "id": "server", "repo": "nextcloud/server" } -] diff --git a/tests/milestone-scripts/configs/stable33.json b/tests/milestone-scripts/configs/stable33.json deleted file mode 100644 index aaa41f13897..00000000000 --- a/tests/milestone-scripts/configs/stable33.json +++ /dev/null @@ -1,3 +0,0 @@ -[ - { "id": "server", "repo": "nextcloud/server" } -] diff --git a/tests/milestone-scripts/configs/stable34.json b/tests/milestone-scripts/configs/stable34.json deleted file mode 100644 index aaa41f13897..00000000000 --- a/tests/milestone-scripts/configs/stable34.json +++ /dev/null @@ -1,3 +0,0 @@ -[ - { "id": "server", "repo": "nextcloud/server" } -] diff --git a/tests/milestone-scripts/configs/tag-only.json b/tests/milestone-scripts/configs/tag-only.json deleted file mode 100644 index fe51488c706..00000000000 --- a/tests/milestone-scripts/configs/tag-only.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/tests/milestone-scripts/configs/unit-config.json b/tests/milestone-scripts/configs/unit-config.json deleted file mode 100644 index 3ab5dada912..00000000000 --- a/tests/milestone-scripts/configs/unit-config.json +++ /dev/null @@ -1,4 +0,0 @@ -[ - { "id": "server", "repo": "nextcloud/server" }, - { "id": "activity", "repo": "nextcloud/activity" } -] diff --git a/tests/milestone-scripts/configs/unit-tagonly.json b/tests/milestone-scripts/configs/unit-tagonly.json deleted file mode 100644 index 6ae07e393a5..00000000000 --- a/tests/milestone-scripts/configs/unit-tagonly.json +++ /dev/null @@ -1,4 +0,0 @@ -[ - "nextcloud/activity", - "nextcloud/updater" -] diff --git a/tests/milestone-scripts/fake-gh.sh b/tests/milestone-scripts/fake-gh.sh deleted file mode 100755 index 04f916f44ae..00000000000 --- a/tests/milestone-scripts/fake-gh.sh +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors -# SPDX-License-Identifier: MIT -# -# Stateful mock of the `gh` CLI for milestone-script tests. -# -# Injected into update-milestones.sh / audit-milestones.sh via the GH env var. -# Serves canned GitHub API responses from a mutable JSON state file and records -# every mutating call (close / create / move) to a journal so tests can assert -# on the exact sequence of side effects. -# -# Environment: -# GH_STATE Path to the mutable state JSON (the runner seeds it from a fixture). -# GH_JOURNAL Path to the append-only mutation log (tab-separated lines). -# -# State JSON shape: -# { -# "milestones": { "": [ {number,title,state,open_issues,due_on}, ... ] }, -# "issues": { "": [ {number,milestone,state}, ... ] }, -# "tags": [ "v33.0.0", "v33.0.4", ... ] -# } -# -# Only the `gh api` surface used by the scripts is implemented; anything else -# is a hard error so unhandled calls fail loudly rather than silently passing. - -# The single-quoted strings below are jq programs using jq's own --arg -# variables ($r, $n, …), not shell parameters - so no shell expansion is wanted. -# shellcheck disable=SC2016 -set -euo pipefail - -STATE="${GH_STATE:?fake-gh requires GH_STATE}" -JOURNAL="${GH_JOURNAL:?fake-gh requires GH_JOURNAL}" - -# Atomically replace the state file with the jq transform read from stdin args. -write_state() { - local tmp - tmp=$(mktemp) - jq "$@" "$STATE" > "$tmp" - mv "$tmp" "$STATE" -} - -# Read one query-string parameter (e.g. page=2) from a foo?a=1&b=2 string. -query_param() { - local name="$1" qs="$2" - # Strip everything up to the path; keep only the query part. - [[ "$qs" == *\?* ]] || { echo ""; return; } - qs="${qs#*\?}" - local kv - for kv in ${qs//&/ }; do - if [[ "$kv" == "${name}="* ]]; then - echo "${kv#*=}" - return - fi - done - echo "" -} - -[[ "${1:-}" == "api" ]] || { echo "fake-gh: only 'api' is supported, got: $*" >&2; exit 1; } -shift - -ENDPOINT="" -METHOD="GET" -JQ="" -PAGINATE=false -declare -A FIELDS=() -while [[ $# -gt 0 ]]; do - case "$1" in - -X) METHOD="$2"; shift 2 ;; - --jq) JQ="$2"; shift 2 ;; - -f|-F) FIELDS["${2%%=*}"]="${2#*=}"; shift 2 ;; - --silent) shift ;; - --paginate) PAGINATE=true; shift ;; - -*) echo "fake-gh: unknown flag: $1" >&2; exit 1 ;; - *) ENDPOINT="$1"; shift ;; - esac -done - -PATH_ONLY="${ENDPOINT%%\?*}" - -# Emit a JSON body, applying the caller's --jq filter exactly like real gh does. -emit() { - if [[ -n "$JQ" ]]; then - jq -r "$JQ" - else - cat - fi -} - -# --- Route by endpoint + method --- - -# Tags listing (audit determines expected state from these). -if [[ "$PATH_ONLY" == "repos/nextcloud-releases/server/git/refs/tags" ]]; then - jq -c '[.tags[] | {ref: ("refs/tags/" + .)}]' "$STATE" | emit - exit 0 -fi - -# Close / update a milestone. A PATCH may set state and/or due_on. -if [[ "$METHOD" == "PATCH" && "$PATH_ONLY" =~ ^repos/(.+)/milestones/([0-9]+)$ ]]; then - repo="${BASH_REMATCH[1]}" - number="${BASH_REMATCH[2]}" - state="${FIELDS[state]:-}" - due="${FIELDS[due_on]:-}" - if [[ -n "$state" ]]; then - write_state --arg r "$repo" --argjson n "$number" --arg s "$state" \ - '(.milestones[$r][]? | select(.number == $n) | .state) = $s' - [[ "$state" == "closed" ]] && printf 'close\t%s\t%s\n' "$repo" "$number" >> "$JOURNAL" - fi - if [[ -n "$due" ]]; then - write_state --arg r "$repo" --argjson n "$number" --arg d "$due" \ - '(.milestones[$r][]? | select(.number == $n) | .due_on) = $d' - printf 'setdue\t%s\t%s\t%s\n' "$repo" "$number" "$due" >> "$JOURNAL" - fi - exit 0 -fi - -# Create a milestone. -if [[ "$METHOD" == "POST" && "$PATH_ONLY" =~ ^repos/(.+)/milestones$ ]]; then - repo="${BASH_REMATCH[1]}" - title="${FIELDS[title]:-}" - due="${FIELDS[due_on]:-}" - number=$(jq '[.milestones[]?[]?.number] | (max // 0) + 1' "$STATE") - write_state --arg r "$repo" --arg t "$title" --argjson n "$number" --arg due "$due" \ - '.milestones[$r] = ((.milestones[$r] // []) + [{ - number: $n, title: $t, state: "open", open_issues: 0, - due_on: (if $due == "" then null else $due end) - }])' - printf 'create\t%s\t%s\tdue=%s\n' "$repo" "$title" "${due:--}" >> "$JOURNAL" - exit 0 -fi - -# List milestones for a repo. Like the real API, a single page returns at most -# 100 entries; only --paginate walks past the first page. -if [[ "$METHOD" == "GET" && "$PATH_ONLY" =~ ^repos/(.+)/milestones$ ]]; then - repo="${BASH_REMATCH[1]}" - if [[ "$PAGINATE" == true ]]; then - jq -c --arg r "$repo" '.milestones[$r] // []' "$STATE" | emit - else - jq -c --arg r "$repo" '(.milestones[$r] // [])[0:100]' "$STATE" | emit - fi - exit 0 -fi - -# Move an issue (change its milestone). -if [[ "$METHOD" == "PATCH" && "$PATH_ONLY" =~ ^repos/(.+)/issues/([0-9]+)$ ]]; then - repo="${BASH_REMATCH[1]}" - number="${BASH_REMATCH[2]}" - milestone="${FIELDS[milestone]:-}" - write_state --arg r "$repo" --argjson i "$number" --argjson m "$milestone" \ - '(.issues[$r][]? | select(.number == $i) | .milestone) = $m' - printf 'move\t%s\t%s\t%s\n' "$repo" "$number" "$milestone" >> "$JOURNAL" - exit 0 -fi - -# List open issues in a milestone, one page of 100 at a time. -if [[ "$METHOD" == "GET" && "$PATH_ONLY" =~ ^repos/(.+)/issues$ ]]; then - repo="${BASH_REMATCH[1]}" - milestone=$(query_param milestone "$ENDPOINT") - page=$(query_param page "$ENDPOINT") - [[ -n "$page" ]] || page=1 - start=$(( (page - 1) * 100 )) - jq -c --arg r "$repo" --argjson m "${milestone:-0}" --argjson start "$start" ' - [.issues[$r][]? | select(.milestone == $m and .state == "open")] - | .[$start : $start + 100] - ' "$STATE" | emit - exit 0 -fi - -echo "fake-gh: unhandled $METHOD $ENDPOINT" >&2 -exit 1 diff --git a/tests/milestone-scripts/run.sh b/tests/milestone-scripts/run.sh deleted file mode 100755 index 80cd88ed672..00000000000 --- a/tests/milestone-scripts/run.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors -# SPDX-License-Identifier: MIT -# -# Snapshot test runner for update-milestones.sh and audit-milestones.sh. -# -# The milestone scripts produce no files - they only call `gh api`. So each -# scenario injects a stateful fake gh (fake-gh.sh) and asserts on: -# - journal.txt : the sequence of mutating API calls (create/close/move) -# - stdout.txt : the script's combined stdout/stderr -# - exit : the script's exit code -# whichever of those the scenario's expected/ directory contains. -# -# Usage: -# run.sh [--update] # --update regenerates expected/ from actual output -# -# Each scenario is a directory under scenarios/ containing: -# args.env - SCRIPT (update|audit), TAG, CONFIG, TAGONLY, EXTRA -# fixture.json - initial gh state (see fake-gh.sh for the shape) -# expected/ - journal.txt / stdout.txt / exit (any subset) - -set -euo pipefail - -TEST_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_DIR="$(cd "$TEST_DIR/../.." && pwd)" -SCENARIOS_DIR="$TEST_DIR/scenarios" -CONFIGS="$TEST_DIR/configs" -FAKE_GH="$TEST_DIR/fake-gh.sh" -UPDATE_SCRIPT="$REPO_DIR/.github/scripts/update-milestones.sh" -AUDIT_SCRIPT="$REPO_DIR/.github/scripts/audit-milestones.sh" - -UPDATE_MODE=false -[[ "${1:-}" == "--update" ]] && UPDATE_MODE=true - -PASS=0 -FAIL=0 -ERRORS="" - -run_scenario() { - local dir="$1" - local name - name=$(basename "$dir") - echo "--- ${name} ---" - - # Scenario args (with defaults) - local SCRIPT TAG CONFIG TAGONLY EXTRA - SCRIPT="update" - TAG="" - CONFIG="stable33.json" - TAGONLY="tag-only.json" - EXTRA="" - # shellcheck source=/dev/null - source "$dir/args.env" - - local state journal stdout_f rc - state=$(mktemp) - journal=$(mktemp) - stdout_f=$(mktemp) - cp "$dir/fixture.json" "$state" - : > "$journal" - - set +e - if [[ "$SCRIPT" == "audit" ]]; then - GH="$FAKE_GH" GH_STATE="$state" GH_JOURNAL="$journal" \ - bash "$AUDIT_SCRIPT" "$CONFIGS/$CONFIG" "$CONFIGS/$TAGONLY" > "$stdout_f" 2>&1 - else - # shellcheck disable=SC2086 # EXTRA intentionally word-splits into flags - GH="$FAKE_GH" GH_STATE="$state" GH_JOURNAL="$journal" \ - bash "$UPDATE_SCRIPT" "$TAG" "$CONFIGS/$CONFIG" "$CONFIGS/$TAGONLY" $EXTRA > "$stdout_f" 2>&1 - fi - rc=$? - set -e - - if [[ "$UPDATE_MODE" == "true" ]]; then - mkdir -p "$dir/expected" - cp "$journal" "$dir/expected/journal.txt" - cp "$stdout_f" "$dir/expected/stdout.txt" - echo "$rc" > "$dir/expected/exit" - echo " UPDATED expected files" - PASS=$((PASS + 1)) - rm -f "$state" "$journal" "$stdout_f" - return - fi - - local failed=false - if [[ -f "$dir/expected/journal.txt" ]] && ! diff -u "$dir/expected/journal.txt" "$journal"; then - echo " FAIL: journal differs" - failed=true - fi - if [[ -f "$dir/expected/stdout.txt" ]] && ! diff -u "$dir/expected/stdout.txt" "$stdout_f"; then - echo " FAIL: stdout differs" - failed=true - fi - if [[ -f "$dir/expected/exit" ]] && [[ "$(cat "$dir/expected/exit")" != "$rc" ]]; then - echo " FAIL: exit code ${rc}, expected $(cat "$dir/expected/exit")" - failed=true - fi - - if [[ "$failed" == "true" ]]; then - FAIL=$((FAIL + 1)) - ERRORS="${ERRORS}\n ${name}" - else - echo " PASS" - PASS=$((PASS + 1)) - fi - - rm -f "$state" "$journal" "$stdout_f" -} - -for scenario in "$SCENARIOS_DIR"/*/; do - [[ -f "$scenario/args.env" ]] || continue - run_scenario "$scenario" -done - -echo "" -echo "=== Results: ${PASS} passed, ${FAIL} failed ===" -if [[ $FAIL -gt 0 ]]; then - echo -e "Failures:${ERRORS}" - exit 1 -fi diff --git a/tests/milestone-scripts/scenarios/audit-healthy/args.env b/tests/milestone-scripts/scenarios/audit-healthy/args.env deleted file mode 100644 index ca3e37e7af8..00000000000 --- a/tests/milestone-scripts/scenarios/audit-healthy/args.env +++ /dev/null @@ -1,2 +0,0 @@ -SCRIPT=audit -CONFIG=stable33.json diff --git a/tests/milestone-scripts/scenarios/audit-healthy/expected/exit b/tests/milestone-scripts/scenarios/audit-healthy/expected/exit deleted file mode 100644 index 573541ac970..00000000000 --- a/tests/milestone-scripts/scenarios/audit-healthy/expected/exit +++ /dev/null @@ -1 +0,0 @@ -0 diff --git a/tests/milestone-scripts/scenarios/audit-healthy/expected/journal.txt b/tests/milestone-scripts/scenarios/audit-healthy/expected/journal.txt deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/milestone-scripts/scenarios/audit-healthy/expected/stdout.txt b/tests/milestone-scripts/scenarios/audit-healthy/expected/stdout.txt deleted file mode 100644 index 8111199efbc..00000000000 --- a/tests/milestone-scripts/scenarios/audit-healthy/expected/stdout.txt +++ /dev/null @@ -1,11 +0,0 @@ -=== Milestone audit for Nextcloud 33 === - Latest stable release: v33.0.4 - Expected closed: Nextcloud 33.0.4 - Expected open: Nextcloud 33.0.5, Nextcloud 33.0.6 - Repos to check: 1 - -Checking nextcloud/server... - -=== Audit summary === - OK: 1 repos - Issues: 0 repos diff --git a/tests/milestone-scripts/scenarios/audit-healthy/fixture.json b/tests/milestone-scripts/scenarios/audit-healthy/fixture.json deleted file mode 100644 index cd289686753..00000000000 --- a/tests/milestone-scripts/scenarios/audit-healthy/fixture.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "milestones": { - "nextcloud/server": [ - {"number":4,"title":"Nextcloud 33.0.4","state":"closed","open_issues":0,"due_on":null}, - {"number":5,"title":"Nextcloud 33.0.5","state":"open","open_issues":3,"due_on":null}, - {"number":6,"title":"Nextcloud 33.0.6","state":"open","open_issues":0,"due_on":"2026-08-01T00:00:00Z"} - ] - }, - "issues": { "nextcloud/server": [] }, - "tags": ["v33.0.0","v33.0.1","v33.0.2","v33.0.3","v33.0.4"] -} diff --git a/tests/milestone-scripts/scenarios/audit-master-inference/args.env b/tests/milestone-scripts/scenarios/audit-master-inference/args.env deleted file mode 100644 index 042c8d4503b..00000000000 --- a/tests/milestone-scripts/scenarios/audit-master-inference/args.env +++ /dev/null @@ -1,2 +0,0 @@ -SCRIPT=audit -CONFIG=master.json diff --git a/tests/milestone-scripts/scenarios/audit-master-inference/expected/exit b/tests/milestone-scripts/scenarios/audit-master-inference/expected/exit deleted file mode 100644 index 573541ac970..00000000000 --- a/tests/milestone-scripts/scenarios/audit-master-inference/expected/exit +++ /dev/null @@ -1 +0,0 @@ -0 diff --git a/tests/milestone-scripts/scenarios/audit-master-inference/expected/journal.txt b/tests/milestone-scripts/scenarios/audit-master-inference/expected/journal.txt deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/milestone-scripts/scenarios/audit-master-inference/expected/stdout.txt b/tests/milestone-scripts/scenarios/audit-master-inference/expected/stdout.txt deleted file mode 100644 index d65f0b757d3..00000000000 --- a/tests/milestone-scripts/scenarios/audit-master-inference/expected/stdout.txt +++ /dev/null @@ -1 +0,0 @@ -No stable release found for major version 35. Skipping audit. diff --git a/tests/milestone-scripts/scenarios/audit-master-inference/fixture.json b/tests/milestone-scripts/scenarios/audit-master-inference/fixture.json deleted file mode 100644 index e96130f65e5..00000000000 --- a/tests/milestone-scripts/scenarios/audit-master-inference/fixture.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "milestones": { "nextcloud/server": [] }, - "issues": { "nextcloud/server": [] }, - "tags": ["v32.0.5","v33.0.4","v34.0.0"] -} diff --git a/tests/milestone-scripts/scenarios/audit-missing-next/args.env b/tests/milestone-scripts/scenarios/audit-missing-next/args.env deleted file mode 100644 index ca3e37e7af8..00000000000 --- a/tests/milestone-scripts/scenarios/audit-missing-next/args.env +++ /dev/null @@ -1,2 +0,0 @@ -SCRIPT=audit -CONFIG=stable33.json diff --git a/tests/milestone-scripts/scenarios/audit-missing-next/expected/exit b/tests/milestone-scripts/scenarios/audit-missing-next/expected/exit deleted file mode 100644 index d00491fd7e5..00000000000 --- a/tests/milestone-scripts/scenarios/audit-missing-next/expected/exit +++ /dev/null @@ -1 +0,0 @@ -1 diff --git a/tests/milestone-scripts/scenarios/audit-missing-next/expected/journal.txt b/tests/milestone-scripts/scenarios/audit-missing-next/expected/journal.txt deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/milestone-scripts/scenarios/audit-missing-next/expected/stdout.txt b/tests/milestone-scripts/scenarios/audit-missing-next/expected/stdout.txt deleted file mode 100644 index 38bae457a20..00000000000 --- a/tests/milestone-scripts/scenarios/audit-missing-next/expected/stdout.txt +++ /dev/null @@ -1,12 +0,0 @@ -=== Milestone audit for Nextcloud 33 === - Latest stable release: v33.0.4 - Expected closed: Nextcloud 33.0.4 - Expected open: Nextcloud 33.0.5, Nextcloud 33.0.6 - Repos to check: 1 - -Checking nextcloud/server... -::warning::nextcloud/server: Missing milestone 'Nextcloud 33.0.5' - -=== Audit summary === - OK: 0 repos - Issues: 1 repos diff --git a/tests/milestone-scripts/scenarios/audit-missing-next/fixture.json b/tests/milestone-scripts/scenarios/audit-missing-next/fixture.json deleted file mode 100644 index 9c334712fce..00000000000 --- a/tests/milestone-scripts/scenarios/audit-missing-next/fixture.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "milestones": { - "nextcloud/server": [ - {"number":4,"title":"Nextcloud 33.0.4","state":"closed","open_issues":0,"due_on":null}, - {"number":6,"title":"Nextcloud 33.0.6","state":"open","open_issues":0,"due_on":"2026-08-01T00:00:00Z"} - ] - }, - "issues": { "nextcloud/server": [] }, - "tags": ["v33.0.0","v33.0.4"] -} diff --git a/tests/milestone-scripts/scenarios/audit-missing-upcoming/args.env b/tests/milestone-scripts/scenarios/audit-missing-upcoming/args.env deleted file mode 100644 index ca3e37e7af8..00000000000 --- a/tests/milestone-scripts/scenarios/audit-missing-upcoming/args.env +++ /dev/null @@ -1,2 +0,0 @@ -SCRIPT=audit -CONFIG=stable33.json diff --git a/tests/milestone-scripts/scenarios/audit-missing-upcoming/expected/exit b/tests/milestone-scripts/scenarios/audit-missing-upcoming/expected/exit deleted file mode 100644 index d00491fd7e5..00000000000 --- a/tests/milestone-scripts/scenarios/audit-missing-upcoming/expected/exit +++ /dev/null @@ -1 +0,0 @@ -1 diff --git a/tests/milestone-scripts/scenarios/audit-missing-upcoming/expected/journal.txt b/tests/milestone-scripts/scenarios/audit-missing-upcoming/expected/journal.txt deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/milestone-scripts/scenarios/audit-missing-upcoming/expected/stdout.txt b/tests/milestone-scripts/scenarios/audit-missing-upcoming/expected/stdout.txt deleted file mode 100644 index e2a46c29793..00000000000 --- a/tests/milestone-scripts/scenarios/audit-missing-upcoming/expected/stdout.txt +++ /dev/null @@ -1,12 +0,0 @@ -=== Milestone audit for Nextcloud 33 === - Latest stable release: v33.0.4 - Expected closed: Nextcloud 33.0.4 - Expected open: Nextcloud 33.0.5, Nextcloud 33.0.6 - Repos to check: 1 - -Checking nextcloud/server... -::warning::nextcloud/server: Missing milestone 'Nextcloud 33.0.6' - -=== Audit summary === - OK: 0 repos - Issues: 1 repos diff --git a/tests/milestone-scripts/scenarios/audit-missing-upcoming/fixture.json b/tests/milestone-scripts/scenarios/audit-missing-upcoming/fixture.json deleted file mode 100644 index 6792a1abbc0..00000000000 --- a/tests/milestone-scripts/scenarios/audit-missing-upcoming/fixture.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "milestones": { - "nextcloud/server": [ - {"number":4,"title":"Nextcloud 33.0.4","state":"closed","open_issues":0,"due_on":null}, - {"number":5,"title":"Nextcloud 33.0.5","state":"open","open_issues":0,"due_on":null} - ] - }, - "issues": { "nextcloud/server": [] }, - "tags": ["v33.0.0","v33.0.4"] -} diff --git a/tests/milestone-scripts/scenarios/audit-no-stable/args.env b/tests/milestone-scripts/scenarios/audit-no-stable/args.env deleted file mode 100644 index ca3e37e7af8..00000000000 --- a/tests/milestone-scripts/scenarios/audit-no-stable/args.env +++ /dev/null @@ -1,2 +0,0 @@ -SCRIPT=audit -CONFIG=stable33.json diff --git a/tests/milestone-scripts/scenarios/audit-no-stable/expected/exit b/tests/milestone-scripts/scenarios/audit-no-stable/expected/exit deleted file mode 100644 index 573541ac970..00000000000 --- a/tests/milestone-scripts/scenarios/audit-no-stable/expected/exit +++ /dev/null @@ -1 +0,0 @@ -0 diff --git a/tests/milestone-scripts/scenarios/audit-no-stable/expected/journal.txt b/tests/milestone-scripts/scenarios/audit-no-stable/expected/journal.txt deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/milestone-scripts/scenarios/audit-no-stable/expected/stdout.txt b/tests/milestone-scripts/scenarios/audit-no-stable/expected/stdout.txt deleted file mode 100644 index 68010e2b1ff..00000000000 --- a/tests/milestone-scripts/scenarios/audit-no-stable/expected/stdout.txt +++ /dev/null @@ -1 +0,0 @@ -No stable release found for major version 33. Skipping audit. diff --git a/tests/milestone-scripts/scenarios/audit-no-stable/fixture.json b/tests/milestone-scripts/scenarios/audit-no-stable/fixture.json deleted file mode 100644 index 670ca6b9dca..00000000000 --- a/tests/milestone-scripts/scenarios/audit-no-stable/fixture.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "milestones": { "nextcloud/server": [] }, - "issues": { "nextcloud/server": [] }, - "tags": ["v31.0.2","v32.0.5"] -} diff --git a/tests/milestone-scripts/scenarios/audit-orphan/args.env b/tests/milestone-scripts/scenarios/audit-orphan/args.env deleted file mode 100644 index ca3e37e7af8..00000000000 --- a/tests/milestone-scripts/scenarios/audit-orphan/args.env +++ /dev/null @@ -1,2 +0,0 @@ -SCRIPT=audit -CONFIG=stable33.json diff --git a/tests/milestone-scripts/scenarios/audit-orphan/expected/exit b/tests/milestone-scripts/scenarios/audit-orphan/expected/exit deleted file mode 100644 index d00491fd7e5..00000000000 --- a/tests/milestone-scripts/scenarios/audit-orphan/expected/exit +++ /dev/null @@ -1 +0,0 @@ -1 diff --git a/tests/milestone-scripts/scenarios/audit-orphan/expected/journal.txt b/tests/milestone-scripts/scenarios/audit-orphan/expected/journal.txt deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/milestone-scripts/scenarios/audit-orphan/expected/stdout.txt b/tests/milestone-scripts/scenarios/audit-orphan/expected/stdout.txt deleted file mode 100644 index c257541e5f8..00000000000 --- a/tests/milestone-scripts/scenarios/audit-orphan/expected/stdout.txt +++ /dev/null @@ -1,12 +0,0 @@ -=== Milestone audit for Nextcloud 33 === - Latest stable release: v33.0.4 - Expected closed: Nextcloud 33.0.4 - Expected open: Nextcloud 33.0.5, Nextcloud 33.0.6 - Repos to check: 1 - -Checking nextcloud/server... -::warning::nextcloud/server: Orphan: 'Nextcloud 33.0.3' still open (2 open issues) - version already released - -=== Audit summary === - OK: 0 repos - Issues: 1 repos diff --git a/tests/milestone-scripts/scenarios/audit-orphan/fixture.json b/tests/milestone-scripts/scenarios/audit-orphan/fixture.json deleted file mode 100644 index be0c7c970ad..00000000000 --- a/tests/milestone-scripts/scenarios/audit-orphan/fixture.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "milestones": { - "nextcloud/server": [ - {"number":3,"title":"Nextcloud 33.0.3","state":"open","open_issues":2,"due_on":null}, - {"number":4,"title":"Nextcloud 33.0.4","state":"closed","open_issues":0,"due_on":null}, - {"number":5,"title":"Nextcloud 33.0.5","state":"open","open_issues":0,"due_on":null}, - {"number":6,"title":"Nextcloud 33.0.6","state":"open","open_issues":0,"due_on":"2026-08-01T00:00:00Z"} - ] - }, - "issues": { "nextcloud/server": [] }, - "tags": ["v33.0.0","v33.0.3","v33.0.4"] -} diff --git a/tests/milestone-scripts/scenarios/audit-released-open/args.env b/tests/milestone-scripts/scenarios/audit-released-open/args.env deleted file mode 100644 index ca3e37e7af8..00000000000 --- a/tests/milestone-scripts/scenarios/audit-released-open/args.env +++ /dev/null @@ -1,2 +0,0 @@ -SCRIPT=audit -CONFIG=stable33.json diff --git a/tests/milestone-scripts/scenarios/audit-released-open/expected/exit b/tests/milestone-scripts/scenarios/audit-released-open/expected/exit deleted file mode 100644 index d00491fd7e5..00000000000 --- a/tests/milestone-scripts/scenarios/audit-released-open/expected/exit +++ /dev/null @@ -1 +0,0 @@ -1 diff --git a/tests/milestone-scripts/scenarios/audit-released-open/expected/journal.txt b/tests/milestone-scripts/scenarios/audit-released-open/expected/journal.txt deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/milestone-scripts/scenarios/audit-released-open/expected/stdout.txt b/tests/milestone-scripts/scenarios/audit-released-open/expected/stdout.txt deleted file mode 100644 index 1811fe01f7e..00000000000 --- a/tests/milestone-scripts/scenarios/audit-released-open/expected/stdout.txt +++ /dev/null @@ -1,13 +0,0 @@ -=== Milestone audit for Nextcloud 33 === - Latest stable release: v33.0.4 - Expected closed: Nextcloud 33.0.4 - Expected open: Nextcloud 33.0.5, Nextcloud 33.0.6 - Repos to check: 1 - -Checking nextcloud/server... -::warning::nextcloud/server: 'Nextcloud 33.0.4' still open (1 open issues) - should be closed -::warning::nextcloud/server: Orphan: 'Nextcloud 33.0.4' still open (1 open issues) - version already released - -=== Audit summary === - OK: 0 repos - Issues: 1 repos diff --git a/tests/milestone-scripts/scenarios/audit-released-open/fixture.json b/tests/milestone-scripts/scenarios/audit-released-open/fixture.json deleted file mode 100644 index b3b752b867e..00000000000 --- a/tests/milestone-scripts/scenarios/audit-released-open/fixture.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "milestones": { - "nextcloud/server": [ - {"number":4,"title":"Nextcloud 33.0.4","state":"open","open_issues":1,"due_on":null}, - {"number":5,"title":"Nextcloud 33.0.5","state":"open","open_issues":0,"due_on":null}, - {"number":6,"title":"Nextcloud 33.0.6","state":"open","open_issues":0,"due_on":"2026-08-01T00:00:00Z"} - ] - }, - "issues": { "nextcloud/server": [] }, - "tags": ["v33.0.0","v33.0.1","v33.0.2","v33.0.3","v33.0.4"] -} diff --git a/tests/milestone-scripts/scenarios/audit-upcoming-no-due/args.env b/tests/milestone-scripts/scenarios/audit-upcoming-no-due/args.env deleted file mode 100644 index ca3e37e7af8..00000000000 --- a/tests/milestone-scripts/scenarios/audit-upcoming-no-due/args.env +++ /dev/null @@ -1,2 +0,0 @@ -SCRIPT=audit -CONFIG=stable33.json diff --git a/tests/milestone-scripts/scenarios/audit-upcoming-no-due/expected/exit b/tests/milestone-scripts/scenarios/audit-upcoming-no-due/expected/exit deleted file mode 100644 index d00491fd7e5..00000000000 --- a/tests/milestone-scripts/scenarios/audit-upcoming-no-due/expected/exit +++ /dev/null @@ -1 +0,0 @@ -1 diff --git a/tests/milestone-scripts/scenarios/audit-upcoming-no-due/expected/journal.txt b/tests/milestone-scripts/scenarios/audit-upcoming-no-due/expected/journal.txt deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/milestone-scripts/scenarios/audit-upcoming-no-due/expected/stdout.txt b/tests/milestone-scripts/scenarios/audit-upcoming-no-due/expected/stdout.txt deleted file mode 100644 index 1b5740404ed..00000000000 --- a/tests/milestone-scripts/scenarios/audit-upcoming-no-due/expected/stdout.txt +++ /dev/null @@ -1,12 +0,0 @@ -=== Milestone audit for Nextcloud 33 === - Latest stable release: v33.0.4 - Expected closed: Nextcloud 33.0.4 - Expected open: Nextcloud 33.0.5, Nextcloud 33.0.6 - Repos to check: 1 - -Checking nextcloud/server... -::warning::nextcloud/server: 'Nextcloud 33.0.6' has no due date - -=== Audit summary === - OK: 0 repos - Issues: 1 repos diff --git a/tests/milestone-scripts/scenarios/audit-upcoming-no-due/fixture.json b/tests/milestone-scripts/scenarios/audit-upcoming-no-due/fixture.json deleted file mode 100644 index b838c8c1326..00000000000 --- a/tests/milestone-scripts/scenarios/audit-upcoming-no-due/fixture.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "milestones": { - "nextcloud/server": [ - {"number":4,"title":"Nextcloud 33.0.4","state":"closed","open_issues":0,"due_on":null}, - {"number":5,"title":"Nextcloud 33.0.5","state":"open","open_issues":0,"due_on":null}, - {"number":6,"title":"Nextcloud 33.0.6","state":"open","open_issues":0,"due_on":null} - ] - }, - "issues": { "nextcloud/server": [] }, - "tags": ["v33.0.0","v33.0.4"] -} diff --git a/tests/milestone-scripts/scenarios/bad-due-date/args.env b/tests/milestone-scripts/scenarios/bad-due-date/args.env deleted file mode 100644 index 0c5047b97f2..00000000000 --- a/tests/milestone-scripts/scenarios/bad-due-date/args.env +++ /dev/null @@ -1,3 +0,0 @@ -SCRIPT=update -TAG=v33.0.4 -EXTRA="--next-due 2026/07/23" diff --git a/tests/milestone-scripts/scenarios/bad-due-date/expected/exit b/tests/milestone-scripts/scenarios/bad-due-date/expected/exit deleted file mode 100644 index d00491fd7e5..00000000000 --- a/tests/milestone-scripts/scenarios/bad-due-date/expected/exit +++ /dev/null @@ -1 +0,0 @@ -1 diff --git a/tests/milestone-scripts/scenarios/bad-due-date/expected/journal.txt b/tests/milestone-scripts/scenarios/bad-due-date/expected/journal.txt deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/milestone-scripts/scenarios/bad-due-date/expected/stdout.txt b/tests/milestone-scripts/scenarios/bad-due-date/expected/stdout.txt deleted file mode 100644 index 92f913510ae..00000000000 --- a/tests/milestone-scripts/scenarios/bad-due-date/expected/stdout.txt +++ /dev/null @@ -1 +0,0 @@ -::error::Invalid --next-due '2026/07/23', expected YYYY-MM-DD diff --git a/tests/milestone-scripts/scenarios/bad-due-date/fixture.json b/tests/milestone-scripts/scenarios/bad-due-date/fixture.json deleted file mode 100644 index aabcf46a2e7..00000000000 --- a/tests/milestone-scripts/scenarios/bad-due-date/fixture.json +++ /dev/null @@ -1 +0,0 @@ -{ "milestones": { "nextcloud/server": [] }, "issues": { "nextcloud/server": [] }, "tags": [] } diff --git a/tests/milestone-scripts/scenarios/due-date-existing/args.env b/tests/milestone-scripts/scenarios/due-date-existing/args.env deleted file mode 100644 index c9b0936afb0..00000000000 --- a/tests/milestone-scripts/scenarios/due-date-existing/args.env +++ /dev/null @@ -1,3 +0,0 @@ -SCRIPT=update -TAG=v33.0.4 -EXTRA="--next-due 2026-07-02 --upcoming-due 2026-08-27" diff --git a/tests/milestone-scripts/scenarios/due-date-existing/expected/exit b/tests/milestone-scripts/scenarios/due-date-existing/expected/exit deleted file mode 100644 index 573541ac970..00000000000 --- a/tests/milestone-scripts/scenarios/due-date-existing/expected/exit +++ /dev/null @@ -1 +0,0 @@ -0 diff --git a/tests/milestone-scripts/scenarios/due-date-existing/expected/journal.txt b/tests/milestone-scripts/scenarios/due-date-existing/expected/journal.txt deleted file mode 100644 index 02771fa9d89..00000000000 --- a/tests/milestone-scripts/scenarios/due-date-existing/expected/journal.txt +++ /dev/null @@ -1,3 +0,0 @@ -setdue nextcloud/server 11 2026-07-02T00:00:00Z -close nextcloud/server 10 -setdue nextcloud/server 12 2026-08-27T00:00:00Z diff --git a/tests/milestone-scripts/scenarios/due-date-existing/expected/stdout.txt b/tests/milestone-scripts/scenarios/due-date-existing/expected/stdout.txt deleted file mode 100644 index a31241e7914..00000000000 --- a/tests/milestone-scripts/scenarios/due-date-existing/expected/stdout.txt +++ /dev/null @@ -1,16 +0,0 @@ -Stable release detected (v33.0.4). - Close: Nextcloud 33.0.4 - Move issues to: Nextcloud 33.0.5 (due: 2026-07-02) - Create: Nextcloud 33.0.6 (due: 2026-08-27) - Repos: 1 - -Processing nextcloud/server... - Set due date of 'Nextcloud 33.0.5' to 2026-07-02T00:00:00Z - Closed milestone 'Nextcloud 33.0.4' (#10) - Milestone 'Nextcloud 33.0.6' already exists (#12) - Set due date of 'Nextcloud 33.0.6' to 2026-08-27T00:00:00Z - -=== Summary === - Milestones closed: 1 - Milestones created: 0 - Issues moved: 0 diff --git a/tests/milestone-scripts/scenarios/due-date-existing/fixture.json b/tests/milestone-scripts/scenarios/due-date-existing/fixture.json deleted file mode 100644 index f381134b5a7..00000000000 --- a/tests/milestone-scripts/scenarios/due-date-existing/fixture.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "milestones": { - "nextcloud/server": [ - {"number":10,"title":"Nextcloud 33.0.4","state":"open","open_issues":0,"due_on":null}, - {"number":11,"title":"Nextcloud 33.0.5","state":"open","open_issues":0,"due_on":"2026-06-25T00:00:00Z"}, - {"number":12,"title":"Nextcloud 33.0.6","state":"open","open_issues":0,"due_on":null} - ] - }, - "issues": { "nextcloud/server": [] }, - "tags": [] -} diff --git a/tests/milestone-scripts/scenarios/due-date/args.env b/tests/milestone-scripts/scenarios/due-date/args.env deleted file mode 100644 index c9b0936afb0..00000000000 --- a/tests/milestone-scripts/scenarios/due-date/args.env +++ /dev/null @@ -1,3 +0,0 @@ -SCRIPT=update -TAG=v33.0.4 -EXTRA="--next-due 2026-07-02 --upcoming-due 2026-08-27" diff --git a/tests/milestone-scripts/scenarios/due-date/expected/exit b/tests/milestone-scripts/scenarios/due-date/expected/exit deleted file mode 100644 index 573541ac970..00000000000 --- a/tests/milestone-scripts/scenarios/due-date/expected/exit +++ /dev/null @@ -1 +0,0 @@ -0 diff --git a/tests/milestone-scripts/scenarios/due-date/expected/journal.txt b/tests/milestone-scripts/scenarios/due-date/expected/journal.txt deleted file mode 100644 index 112bab9b97e..00000000000 --- a/tests/milestone-scripts/scenarios/due-date/expected/journal.txt +++ /dev/null @@ -1,3 +0,0 @@ -setdue nextcloud/server 11 2026-07-02T00:00:00Z -close nextcloud/server 10 -create nextcloud/server Nextcloud 33.0.6 due=2026-08-27T00:00:00Z diff --git a/tests/milestone-scripts/scenarios/due-date/expected/stdout.txt b/tests/milestone-scripts/scenarios/due-date/expected/stdout.txt deleted file mode 100644 index 80c4910648c..00000000000 --- a/tests/milestone-scripts/scenarios/due-date/expected/stdout.txt +++ /dev/null @@ -1,15 +0,0 @@ -Stable release detected (v33.0.4). - Close: Nextcloud 33.0.4 - Move issues to: Nextcloud 33.0.5 (due: 2026-07-02) - Create: Nextcloud 33.0.6 (due: 2026-08-27) - Repos: 1 - -Processing nextcloud/server... - Set due date of 'Nextcloud 33.0.5' to 2026-07-02T00:00:00Z - Closed milestone 'Nextcloud 33.0.4' (#10) - Created milestone 'Nextcloud 33.0.6' - -=== Summary === - Milestones closed: 1 - Milestones created: 1 - Issues moved: 0 diff --git a/tests/milestone-scripts/scenarios/due-date/fixture.json b/tests/milestone-scripts/scenarios/due-date/fixture.json deleted file mode 100644 index ec76ec9d829..00000000000 --- a/tests/milestone-scripts/scenarios/due-date/fixture.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "milestones": { - "nextcloud/server": [ - {"number":10,"title":"Nextcloud 33.0.4","state":"open","open_issues":0,"due_on":null}, - {"number":11,"title":"Nextcloud 33.0.5","state":"open","open_issues":0,"due_on":null} - ] - }, - "issues": { "nextcloud/server": [] }, - "tags": [] -} diff --git a/tests/milestone-scripts/scenarios/existing-upcoming/args.env b/tests/milestone-scripts/scenarios/existing-upcoming/args.env deleted file mode 100644 index dc965b4fb15..00000000000 --- a/tests/milestone-scripts/scenarios/existing-upcoming/args.env +++ /dev/null @@ -1,2 +0,0 @@ -SCRIPT=update -TAG=v33.0.4 diff --git a/tests/milestone-scripts/scenarios/existing-upcoming/expected/exit b/tests/milestone-scripts/scenarios/existing-upcoming/expected/exit deleted file mode 100644 index 573541ac970..00000000000 --- a/tests/milestone-scripts/scenarios/existing-upcoming/expected/exit +++ /dev/null @@ -1 +0,0 @@ -0 diff --git a/tests/milestone-scripts/scenarios/existing-upcoming/expected/journal.txt b/tests/milestone-scripts/scenarios/existing-upcoming/expected/journal.txt deleted file mode 100644 index b5d1d1e412b..00000000000 --- a/tests/milestone-scripts/scenarios/existing-upcoming/expected/journal.txt +++ /dev/null @@ -1,2 +0,0 @@ -move nextcloud/server 100 11 -close nextcloud/server 10 diff --git a/tests/milestone-scripts/scenarios/existing-upcoming/expected/stdout.txt b/tests/milestone-scripts/scenarios/existing-upcoming/expected/stdout.txt deleted file mode 100644 index a1d68a6e388..00000000000 --- a/tests/milestone-scripts/scenarios/existing-upcoming/expected/stdout.txt +++ /dev/null @@ -1,15 +0,0 @@ -Stable release detected (v33.0.4). - Close: Nextcloud 33.0.4 - Move issues to: Nextcloud 33.0.5 - Create: Nextcloud 33.0.6 - Repos: 1 - -Processing nextcloud/server... - Moved #100 → 'Nextcloud 33.0.5' - Closed milestone 'Nextcloud 33.0.4' (#10) - Milestone 'Nextcloud 33.0.6' already exists (#12) - -=== Summary === - Milestones closed: 1 - Milestones created: 0 - Issues moved: 1 diff --git a/tests/milestone-scripts/scenarios/existing-upcoming/fixture.json b/tests/milestone-scripts/scenarios/existing-upcoming/fixture.json deleted file mode 100644 index f778407bcbd..00000000000 --- a/tests/milestone-scripts/scenarios/existing-upcoming/fixture.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "milestones": { - "nextcloud/server": [ - {"number":10,"title":"Nextcloud 33.0.4","state":"open","open_issues":1,"due_on":null}, - {"number":11,"title":"Nextcloud 33.0.5","state":"open","open_issues":0,"due_on":null}, - {"number":12,"title":"Nextcloud 33.0.6","state":"open","open_issues":0,"due_on":null} - ] - }, - "issues": { - "nextcloud/server": [ - {"number":100,"milestone":10,"state":"open"} - ] - }, - "tags": [] -} diff --git a/tests/milestone-scripts/scenarios/first-beta-idempotent/args.env b/tests/milestone-scripts/scenarios/first-beta-idempotent/args.env deleted file mode 100644 index 4d095313e7d..00000000000 --- a/tests/milestone-scripts/scenarios/first-beta-idempotent/args.env +++ /dev/null @@ -1,2 +0,0 @@ -SCRIPT=update -TAG=v35.0.0beta1 diff --git a/tests/milestone-scripts/scenarios/first-beta-idempotent/expected/exit b/tests/milestone-scripts/scenarios/first-beta-idempotent/expected/exit deleted file mode 100644 index 573541ac970..00000000000 --- a/tests/milestone-scripts/scenarios/first-beta-idempotent/expected/exit +++ /dev/null @@ -1 +0,0 @@ -0 diff --git a/tests/milestone-scripts/scenarios/first-beta-idempotent/expected/journal.txt b/tests/milestone-scripts/scenarios/first-beta-idempotent/expected/journal.txt deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/milestone-scripts/scenarios/first-beta-idempotent/expected/stdout.txt b/tests/milestone-scripts/scenarios/first-beta-idempotent/expected/stdout.txt deleted file mode 100644 index bd508df1e76..00000000000 --- a/tests/milestone-scripts/scenarios/first-beta-idempotent/expected/stdout.txt +++ /dev/null @@ -1,8 +0,0 @@ -First beta detected (v35.0.0beta1). Creating milestone 'Nextcloud 36' across 1 repos. -Processing nextcloud/server... - Milestone 'Nextcloud 36' already exists (#30) - -=== Summary === - Milestones closed: 0 - Milestones created: 0 - Issues moved: 0 diff --git a/tests/milestone-scripts/scenarios/first-beta-idempotent/fixture.json b/tests/milestone-scripts/scenarios/first-beta-idempotent/fixture.json deleted file mode 100644 index 30d5f04fbbe..00000000000 --- a/tests/milestone-scripts/scenarios/first-beta-idempotent/fixture.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "milestones": { - "nextcloud/server": [ - {"number":30,"title":"Nextcloud 36","state":"open","open_issues":0,"due_on":null} - ] - }, - "issues": { "nextcloud/server": [] }, - "tags": [] -} diff --git a/tests/milestone-scripts/scenarios/first-beta/args.env b/tests/milestone-scripts/scenarios/first-beta/args.env deleted file mode 100644 index 4d095313e7d..00000000000 --- a/tests/milestone-scripts/scenarios/first-beta/args.env +++ /dev/null @@ -1,2 +0,0 @@ -SCRIPT=update -TAG=v35.0.0beta1 diff --git a/tests/milestone-scripts/scenarios/first-beta/expected/exit b/tests/milestone-scripts/scenarios/first-beta/expected/exit deleted file mode 100644 index 573541ac970..00000000000 --- a/tests/milestone-scripts/scenarios/first-beta/expected/exit +++ /dev/null @@ -1 +0,0 @@ -0 diff --git a/tests/milestone-scripts/scenarios/first-beta/expected/journal.txt b/tests/milestone-scripts/scenarios/first-beta/expected/journal.txt deleted file mode 100644 index 46b507c79fb..00000000000 --- a/tests/milestone-scripts/scenarios/first-beta/expected/journal.txt +++ /dev/null @@ -1 +0,0 @@ -create nextcloud/server Nextcloud 36 due=- diff --git a/tests/milestone-scripts/scenarios/first-beta/expected/stdout.txt b/tests/milestone-scripts/scenarios/first-beta/expected/stdout.txt deleted file mode 100644 index f7f51a625e3..00000000000 --- a/tests/milestone-scripts/scenarios/first-beta/expected/stdout.txt +++ /dev/null @@ -1,8 +0,0 @@ -First beta detected (v35.0.0beta1). Creating milestone 'Nextcloud 36' across 1 repos. -Processing nextcloud/server... - Created milestone 'Nextcloud 36' - -=== Summary === - Milestones closed: 0 - Milestones created: 1 - Issues moved: 0 diff --git a/tests/milestone-scripts/scenarios/first-beta/fixture.json b/tests/milestone-scripts/scenarios/first-beta/fixture.json deleted file mode 100644 index c3cea4fd856..00000000000 --- a/tests/milestone-scripts/scenarios/first-beta/fixture.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "milestones": { "nextcloud/server": [] }, - "issues": { "nextcloud/server": [] }, - "tags": [] -} diff --git a/tests/milestone-scripts/scenarios/first-stable-altname/args.env b/tests/milestone-scripts/scenarios/first-stable-altname/args.env deleted file mode 100644 index c93994ea1ea..00000000000 --- a/tests/milestone-scripts/scenarios/first-stable-altname/args.env +++ /dev/null @@ -1,2 +0,0 @@ -SCRIPT=update -TAG=v34.0.0 diff --git a/tests/milestone-scripts/scenarios/first-stable-altname/expected/exit b/tests/milestone-scripts/scenarios/first-stable-altname/expected/exit deleted file mode 100644 index 573541ac970..00000000000 --- a/tests/milestone-scripts/scenarios/first-stable-altname/expected/exit +++ /dev/null @@ -1 +0,0 @@ -0 diff --git a/tests/milestone-scripts/scenarios/first-stable-altname/expected/journal.txt b/tests/milestone-scripts/scenarios/first-stable-altname/expected/journal.txt deleted file mode 100644 index a2b4789a472..00000000000 --- a/tests/milestone-scripts/scenarios/first-stable-altname/expected/journal.txt +++ /dev/null @@ -1,3 +0,0 @@ -create nextcloud/server Nextcloud 34.0.1 due=- -close nextcloud/server 20 -create nextcloud/server Nextcloud 34.0.2 due=- diff --git a/tests/milestone-scripts/scenarios/first-stable-altname/expected/stdout.txt b/tests/milestone-scripts/scenarios/first-stable-altname/expected/stdout.txt deleted file mode 100644 index fadb8d040d6..00000000000 --- a/tests/milestone-scripts/scenarios/first-stable-altname/expected/stdout.txt +++ /dev/null @@ -1,15 +0,0 @@ -Stable release detected (v34.0.0). - Close: Nextcloud 34.0.0 Nextcloud 34 - Move issues to: Nextcloud 34.0.1 - Create: Nextcloud 34.0.2 - Repos: 1 - -Processing nextcloud/server... - Created milestone 'Nextcloud 34.0.1' - Closed milestone 'Nextcloud 34' (#20) - Created milestone 'Nextcloud 34.0.2' - -=== Summary === - Milestones closed: 1 - Milestones created: 2 - Issues moved: 0 diff --git a/tests/milestone-scripts/scenarios/first-stable-altname/fixture.json b/tests/milestone-scripts/scenarios/first-stable-altname/fixture.json deleted file mode 100644 index 9a954a6b17f..00000000000 --- a/tests/milestone-scripts/scenarios/first-stable-altname/fixture.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "milestones": { - "nextcloud/server": [ - {"number":20,"title":"Nextcloud 34","state":"open","open_issues":0,"due_on":null} - ] - }, - "issues": { "nextcloud/server": [] }, - "tags": [] -} diff --git a/tests/milestone-scripts/scenarios/issue-pagination/args.env b/tests/milestone-scripts/scenarios/issue-pagination/args.env deleted file mode 100644 index dc965b4fb15..00000000000 --- a/tests/milestone-scripts/scenarios/issue-pagination/args.env +++ /dev/null @@ -1,2 +0,0 @@ -SCRIPT=update -TAG=v33.0.4 diff --git a/tests/milestone-scripts/scenarios/issue-pagination/expected/exit b/tests/milestone-scripts/scenarios/issue-pagination/expected/exit deleted file mode 100644 index 573541ac970..00000000000 --- a/tests/milestone-scripts/scenarios/issue-pagination/expected/exit +++ /dev/null @@ -1 +0,0 @@ -0 diff --git a/tests/milestone-scripts/scenarios/issue-pagination/expected/journal.txt b/tests/milestone-scripts/scenarios/issue-pagination/expected/journal.txt deleted file mode 100644 index caed158deed..00000000000 --- a/tests/milestone-scripts/scenarios/issue-pagination/expected/journal.txt +++ /dev/null @@ -1,152 +0,0 @@ -move nextcloud/server 1 11 -move nextcloud/server 2 11 -move nextcloud/server 3 11 -move nextcloud/server 4 11 -move nextcloud/server 5 11 -move nextcloud/server 6 11 -move nextcloud/server 7 11 -move nextcloud/server 8 11 -move nextcloud/server 9 11 -move nextcloud/server 10 11 -move nextcloud/server 11 11 -move nextcloud/server 12 11 -move nextcloud/server 13 11 -move nextcloud/server 14 11 -move nextcloud/server 15 11 -move nextcloud/server 16 11 -move nextcloud/server 17 11 -move nextcloud/server 18 11 -move nextcloud/server 19 11 -move nextcloud/server 20 11 -move nextcloud/server 21 11 -move nextcloud/server 22 11 -move nextcloud/server 23 11 -move nextcloud/server 24 11 -move nextcloud/server 25 11 -move nextcloud/server 26 11 -move nextcloud/server 27 11 -move nextcloud/server 28 11 -move nextcloud/server 29 11 -move nextcloud/server 30 11 -move nextcloud/server 31 11 -move nextcloud/server 32 11 -move nextcloud/server 33 11 -move nextcloud/server 34 11 -move nextcloud/server 35 11 -move nextcloud/server 36 11 -move nextcloud/server 37 11 -move nextcloud/server 38 11 -move nextcloud/server 39 11 -move nextcloud/server 40 11 -move nextcloud/server 41 11 -move nextcloud/server 42 11 -move nextcloud/server 43 11 -move nextcloud/server 44 11 -move nextcloud/server 45 11 -move nextcloud/server 46 11 -move nextcloud/server 47 11 -move nextcloud/server 48 11 -move nextcloud/server 49 11 -move nextcloud/server 50 11 -move nextcloud/server 51 11 -move nextcloud/server 52 11 -move nextcloud/server 53 11 -move nextcloud/server 54 11 -move nextcloud/server 55 11 -move nextcloud/server 56 11 -move nextcloud/server 57 11 -move nextcloud/server 58 11 -move nextcloud/server 59 11 -move nextcloud/server 60 11 -move nextcloud/server 61 11 -move nextcloud/server 62 11 -move nextcloud/server 63 11 -move nextcloud/server 64 11 -move nextcloud/server 65 11 -move nextcloud/server 66 11 -move nextcloud/server 67 11 -move nextcloud/server 68 11 -move nextcloud/server 69 11 -move nextcloud/server 70 11 -move nextcloud/server 71 11 -move nextcloud/server 72 11 -move nextcloud/server 73 11 -move nextcloud/server 74 11 -move nextcloud/server 75 11 -move nextcloud/server 76 11 -move nextcloud/server 77 11 -move nextcloud/server 78 11 -move nextcloud/server 79 11 -move nextcloud/server 80 11 -move nextcloud/server 81 11 -move nextcloud/server 82 11 -move nextcloud/server 83 11 -move nextcloud/server 84 11 -move nextcloud/server 85 11 -move nextcloud/server 86 11 -move nextcloud/server 87 11 -move nextcloud/server 88 11 -move nextcloud/server 89 11 -move nextcloud/server 90 11 -move nextcloud/server 91 11 -move nextcloud/server 92 11 -move nextcloud/server 93 11 -move nextcloud/server 94 11 -move nextcloud/server 95 11 -move nextcloud/server 96 11 -move nextcloud/server 97 11 -move nextcloud/server 98 11 -move nextcloud/server 99 11 -move nextcloud/server 100 11 -move nextcloud/server 101 11 -move nextcloud/server 102 11 -move nextcloud/server 103 11 -move nextcloud/server 104 11 -move nextcloud/server 105 11 -move nextcloud/server 106 11 -move nextcloud/server 107 11 -move nextcloud/server 108 11 -move nextcloud/server 109 11 -move nextcloud/server 110 11 -move nextcloud/server 111 11 -move nextcloud/server 112 11 -move nextcloud/server 113 11 -move nextcloud/server 114 11 -move nextcloud/server 115 11 -move nextcloud/server 116 11 -move nextcloud/server 117 11 -move nextcloud/server 118 11 -move nextcloud/server 119 11 -move nextcloud/server 120 11 -move nextcloud/server 121 11 -move nextcloud/server 122 11 -move nextcloud/server 123 11 -move nextcloud/server 124 11 -move nextcloud/server 125 11 -move nextcloud/server 126 11 -move nextcloud/server 127 11 -move nextcloud/server 128 11 -move nextcloud/server 129 11 -move nextcloud/server 130 11 -move nextcloud/server 131 11 -move nextcloud/server 132 11 -move nextcloud/server 133 11 -move nextcloud/server 134 11 -move nextcloud/server 135 11 -move nextcloud/server 136 11 -move nextcloud/server 137 11 -move nextcloud/server 138 11 -move nextcloud/server 139 11 -move nextcloud/server 140 11 -move nextcloud/server 141 11 -move nextcloud/server 142 11 -move nextcloud/server 143 11 -move nextcloud/server 144 11 -move nextcloud/server 145 11 -move nextcloud/server 146 11 -move nextcloud/server 147 11 -move nextcloud/server 148 11 -move nextcloud/server 149 11 -move nextcloud/server 150 11 -close nextcloud/server 10 -create nextcloud/server Nextcloud 33.0.6 due=- diff --git a/tests/milestone-scripts/scenarios/issue-pagination/expected/stdout.txt b/tests/milestone-scripts/scenarios/issue-pagination/expected/stdout.txt deleted file mode 100644 index 33fe7b56562..00000000000 --- a/tests/milestone-scripts/scenarios/issue-pagination/expected/stdout.txt +++ /dev/null @@ -1,164 +0,0 @@ -Stable release detected (v33.0.4). - Close: Nextcloud 33.0.4 - Move issues to: Nextcloud 33.0.5 - Create: Nextcloud 33.0.6 - Repos: 1 - -Processing nextcloud/server... - Moved #1 → 'Nextcloud 33.0.5' - Moved #2 → 'Nextcloud 33.0.5' - Moved #3 → 'Nextcloud 33.0.5' - Moved #4 → 'Nextcloud 33.0.5' - Moved #5 → 'Nextcloud 33.0.5' - Moved #6 → 'Nextcloud 33.0.5' - Moved #7 → 'Nextcloud 33.0.5' - Moved #8 → 'Nextcloud 33.0.5' - Moved #9 → 'Nextcloud 33.0.5' - Moved #10 → 'Nextcloud 33.0.5' - Moved #11 → 'Nextcloud 33.0.5' - Moved #12 → 'Nextcloud 33.0.5' - Moved #13 → 'Nextcloud 33.0.5' - Moved #14 → 'Nextcloud 33.0.5' - Moved #15 → 'Nextcloud 33.0.5' - Moved #16 → 'Nextcloud 33.0.5' - Moved #17 → 'Nextcloud 33.0.5' - Moved #18 → 'Nextcloud 33.0.5' - Moved #19 → 'Nextcloud 33.0.5' - Moved #20 → 'Nextcloud 33.0.5' - Moved #21 → 'Nextcloud 33.0.5' - Moved #22 → 'Nextcloud 33.0.5' - Moved #23 → 'Nextcloud 33.0.5' - Moved #24 → 'Nextcloud 33.0.5' - Moved #25 → 'Nextcloud 33.0.5' - Moved #26 → 'Nextcloud 33.0.5' - Moved #27 → 'Nextcloud 33.0.5' - Moved #28 → 'Nextcloud 33.0.5' - Moved #29 → 'Nextcloud 33.0.5' - Moved #30 → 'Nextcloud 33.0.5' - Moved #31 → 'Nextcloud 33.0.5' - Moved #32 → 'Nextcloud 33.0.5' - Moved #33 → 'Nextcloud 33.0.5' - Moved #34 → 'Nextcloud 33.0.5' - Moved #35 → 'Nextcloud 33.0.5' - Moved #36 → 'Nextcloud 33.0.5' - Moved #37 → 'Nextcloud 33.0.5' - Moved #38 → 'Nextcloud 33.0.5' - Moved #39 → 'Nextcloud 33.0.5' - Moved #40 → 'Nextcloud 33.0.5' - Moved #41 → 'Nextcloud 33.0.5' - Moved #42 → 'Nextcloud 33.0.5' - Moved #43 → 'Nextcloud 33.0.5' - Moved #44 → 'Nextcloud 33.0.5' - Moved #45 → 'Nextcloud 33.0.5' - Moved #46 → 'Nextcloud 33.0.5' - Moved #47 → 'Nextcloud 33.0.5' - Moved #48 → 'Nextcloud 33.0.5' - Moved #49 → 'Nextcloud 33.0.5' - Moved #50 → 'Nextcloud 33.0.5' - Moved #51 → 'Nextcloud 33.0.5' - Moved #52 → 'Nextcloud 33.0.5' - Moved #53 → 'Nextcloud 33.0.5' - Moved #54 → 'Nextcloud 33.0.5' - Moved #55 → 'Nextcloud 33.0.5' - Moved #56 → 'Nextcloud 33.0.5' - Moved #57 → 'Nextcloud 33.0.5' - Moved #58 → 'Nextcloud 33.0.5' - Moved #59 → 'Nextcloud 33.0.5' - Moved #60 → 'Nextcloud 33.0.5' - Moved #61 → 'Nextcloud 33.0.5' - Moved #62 → 'Nextcloud 33.0.5' - Moved #63 → 'Nextcloud 33.0.5' - Moved #64 → 'Nextcloud 33.0.5' - Moved #65 → 'Nextcloud 33.0.5' - Moved #66 → 'Nextcloud 33.0.5' - Moved #67 → 'Nextcloud 33.0.5' - Moved #68 → 'Nextcloud 33.0.5' - Moved #69 → 'Nextcloud 33.0.5' - Moved #70 → 'Nextcloud 33.0.5' - Moved #71 → 'Nextcloud 33.0.5' - Moved #72 → 'Nextcloud 33.0.5' - Moved #73 → 'Nextcloud 33.0.5' - Moved #74 → 'Nextcloud 33.0.5' - Moved #75 → 'Nextcloud 33.0.5' - Moved #76 → 'Nextcloud 33.0.5' - Moved #77 → 'Nextcloud 33.0.5' - Moved #78 → 'Nextcloud 33.0.5' - Moved #79 → 'Nextcloud 33.0.5' - Moved #80 → 'Nextcloud 33.0.5' - Moved #81 → 'Nextcloud 33.0.5' - Moved #82 → 'Nextcloud 33.0.5' - Moved #83 → 'Nextcloud 33.0.5' - Moved #84 → 'Nextcloud 33.0.5' - Moved #85 → 'Nextcloud 33.0.5' - Moved #86 → 'Nextcloud 33.0.5' - Moved #87 → 'Nextcloud 33.0.5' - Moved #88 → 'Nextcloud 33.0.5' - Moved #89 → 'Nextcloud 33.0.5' - Moved #90 → 'Nextcloud 33.0.5' - Moved #91 → 'Nextcloud 33.0.5' - Moved #92 → 'Nextcloud 33.0.5' - Moved #93 → 'Nextcloud 33.0.5' - Moved #94 → 'Nextcloud 33.0.5' - Moved #95 → 'Nextcloud 33.0.5' - Moved #96 → 'Nextcloud 33.0.5' - Moved #97 → 'Nextcloud 33.0.5' - Moved #98 → 'Nextcloud 33.0.5' - Moved #99 → 'Nextcloud 33.0.5' - Moved #100 → 'Nextcloud 33.0.5' - Moved #101 → 'Nextcloud 33.0.5' - Moved #102 → 'Nextcloud 33.0.5' - Moved #103 → 'Nextcloud 33.0.5' - Moved #104 → 'Nextcloud 33.0.5' - Moved #105 → 'Nextcloud 33.0.5' - Moved #106 → 'Nextcloud 33.0.5' - Moved #107 → 'Nextcloud 33.0.5' - Moved #108 → 'Nextcloud 33.0.5' - Moved #109 → 'Nextcloud 33.0.5' - Moved #110 → 'Nextcloud 33.0.5' - Moved #111 → 'Nextcloud 33.0.5' - Moved #112 → 'Nextcloud 33.0.5' - Moved #113 → 'Nextcloud 33.0.5' - Moved #114 → 'Nextcloud 33.0.5' - Moved #115 → 'Nextcloud 33.0.5' - Moved #116 → 'Nextcloud 33.0.5' - Moved #117 → 'Nextcloud 33.0.5' - Moved #118 → 'Nextcloud 33.0.5' - Moved #119 → 'Nextcloud 33.0.5' - Moved #120 → 'Nextcloud 33.0.5' - Moved #121 → 'Nextcloud 33.0.5' - Moved #122 → 'Nextcloud 33.0.5' - Moved #123 → 'Nextcloud 33.0.5' - Moved #124 → 'Nextcloud 33.0.5' - Moved #125 → 'Nextcloud 33.0.5' - Moved #126 → 'Nextcloud 33.0.5' - Moved #127 → 'Nextcloud 33.0.5' - Moved #128 → 'Nextcloud 33.0.5' - Moved #129 → 'Nextcloud 33.0.5' - Moved #130 → 'Nextcloud 33.0.5' - Moved #131 → 'Nextcloud 33.0.5' - Moved #132 → 'Nextcloud 33.0.5' - Moved #133 → 'Nextcloud 33.0.5' - Moved #134 → 'Nextcloud 33.0.5' - Moved #135 → 'Nextcloud 33.0.5' - Moved #136 → 'Nextcloud 33.0.5' - Moved #137 → 'Nextcloud 33.0.5' - Moved #138 → 'Nextcloud 33.0.5' - Moved #139 → 'Nextcloud 33.0.5' - Moved #140 → 'Nextcloud 33.0.5' - Moved #141 → 'Nextcloud 33.0.5' - Moved #142 → 'Nextcloud 33.0.5' - Moved #143 → 'Nextcloud 33.0.5' - Moved #144 → 'Nextcloud 33.0.5' - Moved #145 → 'Nextcloud 33.0.5' - Moved #146 → 'Nextcloud 33.0.5' - Moved #147 → 'Nextcloud 33.0.5' - Moved #148 → 'Nextcloud 33.0.5' - Moved #149 → 'Nextcloud 33.0.5' - Moved #150 → 'Nextcloud 33.0.5' - Closed milestone 'Nextcloud 33.0.4' (#10) - Created milestone 'Nextcloud 33.0.6' - -=== Summary === - Milestones closed: 1 - Milestones created: 1 - Issues moved: 150 diff --git a/tests/milestone-scripts/scenarios/issue-pagination/fixture.json b/tests/milestone-scripts/scenarios/issue-pagination/fixture.json deleted file mode 100644 index 1d84a3c874a..00000000000 --- a/tests/milestone-scripts/scenarios/issue-pagination/fixture.json +++ /dev/null @@ -1,775 +0,0 @@ -{ - "milestones": { - "nextcloud/server": [ - { - "number": 10, - "title": "Nextcloud 33.0.4", - "state": "open", - "open_issues": 150, - "due_on": null - }, - { - "number": 11, - "title": "Nextcloud 33.0.5", - "state": "open", - "open_issues": 0, - "due_on": null - } - ] - }, - "issues": { - "nextcloud/server": [ - { - "number": 1, - "milestone": 10, - "state": "open" - }, - { - "number": 2, - "milestone": 10, - "state": "open" - }, - { - "number": 3, - "milestone": 10, - "state": "open" - }, - { - "number": 4, - "milestone": 10, - "state": "open" - }, - { - "number": 5, - "milestone": 10, - "state": "open" - }, - { - "number": 6, - "milestone": 10, - "state": "open" - }, - { - "number": 7, - "milestone": 10, - "state": "open" - }, - { - "number": 8, - "milestone": 10, - "state": "open" - }, - { - "number": 9, - "milestone": 10, - "state": "open" - }, - { - "number": 10, - "milestone": 10, - "state": "open" - }, - { - "number": 11, - "milestone": 10, - "state": "open" - }, - { - "number": 12, - "milestone": 10, - "state": "open" - }, - { - "number": 13, - "milestone": 10, - "state": "open" - }, - { - "number": 14, - "milestone": 10, - "state": "open" - }, - { - "number": 15, - "milestone": 10, - "state": "open" - }, - { - "number": 16, - "milestone": 10, - "state": "open" - }, - { - "number": 17, - "milestone": 10, - "state": "open" - }, - { - "number": 18, - "milestone": 10, - "state": "open" - }, - { - "number": 19, - "milestone": 10, - "state": "open" - }, - { - "number": 20, - "milestone": 10, - "state": "open" - }, - { - "number": 21, - "milestone": 10, - "state": "open" - }, - { - "number": 22, - "milestone": 10, - "state": "open" - }, - { - "number": 23, - "milestone": 10, - "state": "open" - }, - { - "number": 24, - "milestone": 10, - "state": "open" - }, - { - "number": 25, - "milestone": 10, - "state": "open" - }, - { - "number": 26, - "milestone": 10, - "state": "open" - }, - { - "number": 27, - "milestone": 10, - "state": "open" - }, - { - "number": 28, - "milestone": 10, - "state": "open" - }, - { - "number": 29, - "milestone": 10, - "state": "open" - }, - { - "number": 30, - "milestone": 10, - "state": "open" - }, - { - "number": 31, - "milestone": 10, - "state": "open" - }, - { - "number": 32, - "milestone": 10, - "state": "open" - }, - { - "number": 33, - "milestone": 10, - "state": "open" - }, - { - "number": 34, - "milestone": 10, - "state": "open" - }, - { - "number": 35, - "milestone": 10, - "state": "open" - }, - { - "number": 36, - "milestone": 10, - "state": "open" - }, - { - "number": 37, - "milestone": 10, - "state": "open" - }, - { - "number": 38, - "milestone": 10, - "state": "open" - }, - { - "number": 39, - "milestone": 10, - "state": "open" - }, - { - "number": 40, - "milestone": 10, - "state": "open" - }, - { - "number": 41, - "milestone": 10, - "state": "open" - }, - { - "number": 42, - "milestone": 10, - "state": "open" - }, - { - "number": 43, - "milestone": 10, - "state": "open" - }, - { - "number": 44, - "milestone": 10, - "state": "open" - }, - { - "number": 45, - "milestone": 10, - "state": "open" - }, - { - "number": 46, - "milestone": 10, - "state": "open" - }, - { - "number": 47, - "milestone": 10, - "state": "open" - }, - { - "number": 48, - "milestone": 10, - "state": "open" - }, - { - "number": 49, - "milestone": 10, - "state": "open" - }, - { - "number": 50, - "milestone": 10, - "state": "open" - }, - { - "number": 51, - "milestone": 10, - "state": "open" - }, - { - "number": 52, - "milestone": 10, - "state": "open" - }, - { - "number": 53, - "milestone": 10, - "state": "open" - }, - { - "number": 54, - "milestone": 10, - "state": "open" - }, - { - "number": 55, - "milestone": 10, - "state": "open" - }, - { - "number": 56, - "milestone": 10, - "state": "open" - }, - { - "number": 57, - "milestone": 10, - "state": "open" - }, - { - "number": 58, - "milestone": 10, - "state": "open" - }, - { - "number": 59, - "milestone": 10, - "state": "open" - }, - { - "number": 60, - "milestone": 10, - "state": "open" - }, - { - "number": 61, - "milestone": 10, - "state": "open" - }, - { - "number": 62, - "milestone": 10, - "state": "open" - }, - { - "number": 63, - "milestone": 10, - "state": "open" - }, - { - "number": 64, - "milestone": 10, - "state": "open" - }, - { - "number": 65, - "milestone": 10, - "state": "open" - }, - { - "number": 66, - "milestone": 10, - "state": "open" - }, - { - "number": 67, - "milestone": 10, - "state": "open" - }, - { - "number": 68, - "milestone": 10, - "state": "open" - }, - { - "number": 69, - "milestone": 10, - "state": "open" - }, - { - "number": 70, - "milestone": 10, - "state": "open" - }, - { - "number": 71, - "milestone": 10, - "state": "open" - }, - { - "number": 72, - "milestone": 10, - "state": "open" - }, - { - "number": 73, - "milestone": 10, - "state": "open" - }, - { - "number": 74, - "milestone": 10, - "state": "open" - }, - { - "number": 75, - "milestone": 10, - "state": "open" - }, - { - "number": 76, - "milestone": 10, - "state": "open" - }, - { - "number": 77, - "milestone": 10, - "state": "open" - }, - { - "number": 78, - "milestone": 10, - "state": "open" - }, - { - "number": 79, - "milestone": 10, - "state": "open" - }, - { - "number": 80, - "milestone": 10, - "state": "open" - }, - { - "number": 81, - "milestone": 10, - "state": "open" - }, - { - "number": 82, - "milestone": 10, - "state": "open" - }, - { - "number": 83, - "milestone": 10, - "state": "open" - }, - { - "number": 84, - "milestone": 10, - "state": "open" - }, - { - "number": 85, - "milestone": 10, - "state": "open" - }, - { - "number": 86, - "milestone": 10, - "state": "open" - }, - { - "number": 87, - "milestone": 10, - "state": "open" - }, - { - "number": 88, - "milestone": 10, - "state": "open" - }, - { - "number": 89, - "milestone": 10, - "state": "open" - }, - { - "number": 90, - "milestone": 10, - "state": "open" - }, - { - "number": 91, - "milestone": 10, - "state": "open" - }, - { - "number": 92, - "milestone": 10, - "state": "open" - }, - { - "number": 93, - "milestone": 10, - "state": "open" - }, - { - "number": 94, - "milestone": 10, - "state": "open" - }, - { - "number": 95, - "milestone": 10, - "state": "open" - }, - { - "number": 96, - "milestone": 10, - "state": "open" - }, - { - "number": 97, - "milestone": 10, - "state": "open" - }, - { - "number": 98, - "milestone": 10, - "state": "open" - }, - { - "number": 99, - "milestone": 10, - "state": "open" - }, - { - "number": 100, - "milestone": 10, - "state": "open" - }, - { - "number": 101, - "milestone": 10, - "state": "open" - }, - { - "number": 102, - "milestone": 10, - "state": "open" - }, - { - "number": 103, - "milestone": 10, - "state": "open" - }, - { - "number": 104, - "milestone": 10, - "state": "open" - }, - { - "number": 105, - "milestone": 10, - "state": "open" - }, - { - "number": 106, - "milestone": 10, - "state": "open" - }, - { - "number": 107, - "milestone": 10, - "state": "open" - }, - { - "number": 108, - "milestone": 10, - "state": "open" - }, - { - "number": 109, - "milestone": 10, - "state": "open" - }, - { - "number": 110, - "milestone": 10, - "state": "open" - }, - { - "number": 111, - "milestone": 10, - "state": "open" - }, - { - "number": 112, - "milestone": 10, - "state": "open" - }, - { - "number": 113, - "milestone": 10, - "state": "open" - }, - { - "number": 114, - "milestone": 10, - "state": "open" - }, - { - "number": 115, - "milestone": 10, - "state": "open" - }, - { - "number": 116, - "milestone": 10, - "state": "open" - }, - { - "number": 117, - "milestone": 10, - "state": "open" - }, - { - "number": 118, - "milestone": 10, - "state": "open" - }, - { - "number": 119, - "milestone": 10, - "state": "open" - }, - { - "number": 120, - "milestone": 10, - "state": "open" - }, - { - "number": 121, - "milestone": 10, - "state": "open" - }, - { - "number": 122, - "milestone": 10, - "state": "open" - }, - { - "number": 123, - "milestone": 10, - "state": "open" - }, - { - "number": 124, - "milestone": 10, - "state": "open" - }, - { - "number": 125, - "milestone": 10, - "state": "open" - }, - { - "number": 126, - "milestone": 10, - "state": "open" - }, - { - "number": 127, - "milestone": 10, - "state": "open" - }, - { - "number": 128, - "milestone": 10, - "state": "open" - }, - { - "number": 129, - "milestone": 10, - "state": "open" - }, - { - "number": 130, - "milestone": 10, - "state": "open" - }, - { - "number": 131, - "milestone": 10, - "state": "open" - }, - { - "number": 132, - "milestone": 10, - "state": "open" - }, - { - "number": 133, - "milestone": 10, - "state": "open" - }, - { - "number": 134, - "milestone": 10, - "state": "open" - }, - { - "number": 135, - "milestone": 10, - "state": "open" - }, - { - "number": 136, - "milestone": 10, - "state": "open" - }, - { - "number": 137, - "milestone": 10, - "state": "open" - }, - { - "number": 138, - "milestone": 10, - "state": "open" - }, - { - "number": 139, - "milestone": 10, - "state": "open" - }, - { - "number": 140, - "milestone": 10, - "state": "open" - }, - { - "number": 141, - "milestone": 10, - "state": "open" - }, - { - "number": 142, - "milestone": 10, - "state": "open" - }, - { - "number": 143, - "milestone": 10, - "state": "open" - }, - { - "number": 144, - "milestone": 10, - "state": "open" - }, - { - "number": 145, - "milestone": 10, - "state": "open" - }, - { - "number": 146, - "milestone": 10, - "state": "open" - }, - { - "number": 147, - "milestone": 10, - "state": "open" - }, - { - "number": 148, - "milestone": 10, - "state": "open" - }, - { - "number": 149, - "milestone": 10, - "state": "open" - }, - { - "number": 150, - "milestone": 10, - "state": "open" - } - ] - }, - "tags": [] -} diff --git a/tests/milestone-scripts/scenarios/missing-next/args.env b/tests/milestone-scripts/scenarios/missing-next/args.env deleted file mode 100644 index dc965b4fb15..00000000000 --- a/tests/milestone-scripts/scenarios/missing-next/args.env +++ /dev/null @@ -1,2 +0,0 @@ -SCRIPT=update -TAG=v33.0.4 diff --git a/tests/milestone-scripts/scenarios/missing-next/expected/exit b/tests/milestone-scripts/scenarios/missing-next/expected/exit deleted file mode 100644 index 573541ac970..00000000000 --- a/tests/milestone-scripts/scenarios/missing-next/expected/exit +++ /dev/null @@ -1 +0,0 @@ -0 diff --git a/tests/milestone-scripts/scenarios/missing-next/expected/journal.txt b/tests/milestone-scripts/scenarios/missing-next/expected/journal.txt deleted file mode 100644 index 6b42607169b..00000000000 --- a/tests/milestone-scripts/scenarios/missing-next/expected/journal.txt +++ /dev/null @@ -1,4 +0,0 @@ -create nextcloud/server Nextcloud 33.0.5 due=- -move nextcloud/server 100 11 -close nextcloud/server 10 -create nextcloud/server Nextcloud 33.0.6 due=- diff --git a/tests/milestone-scripts/scenarios/missing-next/expected/stdout.txt b/tests/milestone-scripts/scenarios/missing-next/expected/stdout.txt deleted file mode 100644 index ed1d6467f04..00000000000 --- a/tests/milestone-scripts/scenarios/missing-next/expected/stdout.txt +++ /dev/null @@ -1,16 +0,0 @@ -Stable release detected (v33.0.4). - Close: Nextcloud 33.0.4 - Move issues to: Nextcloud 33.0.5 - Create: Nextcloud 33.0.6 - Repos: 1 - -Processing nextcloud/server... - Created milestone 'Nextcloud 33.0.5' - Moved #100 → 'Nextcloud 33.0.5' - Closed milestone 'Nextcloud 33.0.4' (#10) - Created milestone 'Nextcloud 33.0.6' - -=== Summary === - Milestones closed: 1 - Milestones created: 2 - Issues moved: 1 diff --git a/tests/milestone-scripts/scenarios/missing-next/fixture.json b/tests/milestone-scripts/scenarios/missing-next/fixture.json deleted file mode 100644 index 2be4484ba68..00000000000 --- a/tests/milestone-scripts/scenarios/missing-next/fixture.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "milestones": { - "nextcloud/server": [ - {"number":10,"title":"Nextcloud 33.0.4","state":"open","open_issues":1,"due_on":null} - ] - }, - "issues": { - "nextcloud/server": [ - {"number":100,"milestone":10,"state":"open"} - ] - }, - "tags": [] -} diff --git a/tests/milestone-scripts/scenarios/paginated-lookup/args.env b/tests/milestone-scripts/scenarios/paginated-lookup/args.env deleted file mode 100644 index b34e0b6f5d1..00000000000 --- a/tests/milestone-scripts/scenarios/paginated-lookup/args.env +++ /dev/null @@ -1,2 +0,0 @@ -SCRIPT=update -TAG=v34.0.4 diff --git a/tests/milestone-scripts/scenarios/paginated-lookup/expected/exit b/tests/milestone-scripts/scenarios/paginated-lookup/expected/exit deleted file mode 100644 index 573541ac970..00000000000 --- a/tests/milestone-scripts/scenarios/paginated-lookup/expected/exit +++ /dev/null @@ -1 +0,0 @@ -0 diff --git a/tests/milestone-scripts/scenarios/paginated-lookup/expected/journal.txt b/tests/milestone-scripts/scenarios/paginated-lookup/expected/journal.txt deleted file mode 100644 index 6659d90083b..00000000000 --- a/tests/milestone-scripts/scenarios/paginated-lookup/expected/journal.txt +++ /dev/null @@ -1,3 +0,0 @@ -move nextcloud/server 500 201 -close nextcloud/server 200 -create nextcloud/server Nextcloud 34.0.6 due=- diff --git a/tests/milestone-scripts/scenarios/paginated-lookup/expected/stdout.txt b/tests/milestone-scripts/scenarios/paginated-lookup/expected/stdout.txt deleted file mode 100644 index bf4ad36fdf2..00000000000 --- a/tests/milestone-scripts/scenarios/paginated-lookup/expected/stdout.txt +++ /dev/null @@ -1,15 +0,0 @@ -Stable release detected (v34.0.4). - Close: Nextcloud 34.0.4 - Move issues to: Nextcloud 34.0.5 - Create: Nextcloud 34.0.6 - Repos: 1 - -Processing nextcloud/server... - Moved #500 → 'Nextcloud 34.0.5' - Closed milestone 'Nextcloud 34.0.4' (#200) - Created milestone 'Nextcloud 34.0.6' - -=== Summary === - Milestones closed: 1 - Milestones created: 1 - Issues moved: 1 diff --git a/tests/milestone-scripts/scenarios/paginated-lookup/fixture.json b/tests/milestone-scripts/scenarios/paginated-lookup/fixture.json deleted file mode 100644 index 0fa0b9bd91c..00000000000 --- a/tests/milestone-scripts/scenarios/paginated-lookup/fixture.json +++ /dev/null @@ -1,730 +0,0 @@ -{ - "milestones": { - "nextcloud/server": [ - { - "number": 1, - "title": "Old milestone 1", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 2, - "title": "Old milestone 2", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 3, - "title": "Old milestone 3", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 4, - "title": "Old milestone 4", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 5, - "title": "Old milestone 5", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 6, - "title": "Old milestone 6", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 7, - "title": "Old milestone 7", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 8, - "title": "Old milestone 8", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 9, - "title": "Old milestone 9", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 10, - "title": "Old milestone 10", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 11, - "title": "Old milestone 11", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 12, - "title": "Old milestone 12", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 13, - "title": "Old milestone 13", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 14, - "title": "Old milestone 14", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 15, - "title": "Old milestone 15", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 16, - "title": "Old milestone 16", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 17, - "title": "Old milestone 17", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 18, - "title": "Old milestone 18", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 19, - "title": "Old milestone 19", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 20, - "title": "Old milestone 20", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 21, - "title": "Old milestone 21", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 22, - "title": "Old milestone 22", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 23, - "title": "Old milestone 23", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 24, - "title": "Old milestone 24", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 25, - "title": "Old milestone 25", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 26, - "title": "Old milestone 26", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 27, - "title": "Old milestone 27", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 28, - "title": "Old milestone 28", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 29, - "title": "Old milestone 29", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 30, - "title": "Old milestone 30", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 31, - "title": "Old milestone 31", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 32, - "title": "Old milestone 32", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 33, - "title": "Old milestone 33", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 34, - "title": "Old milestone 34", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 35, - "title": "Old milestone 35", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 36, - "title": "Old milestone 36", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 37, - "title": "Old milestone 37", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 38, - "title": "Old milestone 38", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 39, - "title": "Old milestone 39", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 40, - "title": "Old milestone 40", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 41, - "title": "Old milestone 41", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 42, - "title": "Old milestone 42", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 43, - "title": "Old milestone 43", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 44, - "title": "Old milestone 44", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 45, - "title": "Old milestone 45", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 46, - "title": "Old milestone 46", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 47, - "title": "Old milestone 47", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 48, - "title": "Old milestone 48", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 49, - "title": "Old milestone 49", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 50, - "title": "Old milestone 50", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 51, - "title": "Old milestone 51", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 52, - "title": "Old milestone 52", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 53, - "title": "Old milestone 53", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 54, - "title": "Old milestone 54", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 55, - "title": "Old milestone 55", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 56, - "title": "Old milestone 56", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 57, - "title": "Old milestone 57", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 58, - "title": "Old milestone 58", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 59, - "title": "Old milestone 59", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 60, - "title": "Old milestone 60", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 61, - "title": "Old milestone 61", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 62, - "title": "Old milestone 62", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 63, - "title": "Old milestone 63", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 64, - "title": "Old milestone 64", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 65, - "title": "Old milestone 65", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 66, - "title": "Old milestone 66", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 67, - "title": "Old milestone 67", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 68, - "title": "Old milestone 68", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 69, - "title": "Old milestone 69", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 70, - "title": "Old milestone 70", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 71, - "title": "Old milestone 71", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 72, - "title": "Old milestone 72", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 73, - "title": "Old milestone 73", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 74, - "title": "Old milestone 74", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 75, - "title": "Old milestone 75", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 76, - "title": "Old milestone 76", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 77, - "title": "Old milestone 77", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 78, - "title": "Old milestone 78", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 79, - "title": "Old milestone 79", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 80, - "title": "Old milestone 80", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 81, - "title": "Old milestone 81", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 82, - "title": "Old milestone 82", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 83, - "title": "Old milestone 83", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 84, - "title": "Old milestone 84", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 85, - "title": "Old milestone 85", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 86, - "title": "Old milestone 86", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 87, - "title": "Old milestone 87", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 88, - "title": "Old milestone 88", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 89, - "title": "Old milestone 89", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 90, - "title": "Old milestone 90", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 91, - "title": "Old milestone 91", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 92, - "title": "Old milestone 92", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 93, - "title": "Old milestone 93", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 94, - "title": "Old milestone 94", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 95, - "title": "Old milestone 95", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 96, - "title": "Old milestone 96", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 97, - "title": "Old milestone 97", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 98, - "title": "Old milestone 98", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 99, - "title": "Old milestone 99", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 100, - "title": "Old milestone 100", - "state": "closed", - "open_issues": 0, - "due_on": null - }, - { - "number": 200, - "title": "Nextcloud 34.0.4", - "state": "open", - "open_issues": 1, - "due_on": null - }, - { - "number": 201, - "title": "Nextcloud 34.0.5", - "state": "open", - "open_issues": 0, - "due_on": null - } - ] - }, - "issues": { - "nextcloud/server": [ - { - "number": 500, - "milestone": 200, - "state": "open" - } - ] - }, - "tags": [] -} diff --git a/tests/milestone-scripts/scenarios/patch-release/args.env b/tests/milestone-scripts/scenarios/patch-release/args.env deleted file mode 100644 index dc965b4fb15..00000000000 --- a/tests/milestone-scripts/scenarios/patch-release/args.env +++ /dev/null @@ -1,2 +0,0 @@ -SCRIPT=update -TAG=v33.0.4 diff --git a/tests/milestone-scripts/scenarios/patch-release/expected/exit b/tests/milestone-scripts/scenarios/patch-release/expected/exit deleted file mode 100644 index 573541ac970..00000000000 --- a/tests/milestone-scripts/scenarios/patch-release/expected/exit +++ /dev/null @@ -1 +0,0 @@ -0 diff --git a/tests/milestone-scripts/scenarios/patch-release/expected/journal.txt b/tests/milestone-scripts/scenarios/patch-release/expected/journal.txt deleted file mode 100644 index 0dcaa7f195a..00000000000 --- a/tests/milestone-scripts/scenarios/patch-release/expected/journal.txt +++ /dev/null @@ -1,4 +0,0 @@ -move nextcloud/server 100 11 -move nextcloud/server 101 11 -close nextcloud/server 10 -create nextcloud/server Nextcloud 33.0.6 due=- diff --git a/tests/milestone-scripts/scenarios/patch-release/expected/stdout.txt b/tests/milestone-scripts/scenarios/patch-release/expected/stdout.txt deleted file mode 100644 index 8df9b681930..00000000000 --- a/tests/milestone-scripts/scenarios/patch-release/expected/stdout.txt +++ /dev/null @@ -1,16 +0,0 @@ -Stable release detected (v33.0.4). - Close: Nextcloud 33.0.4 - Move issues to: Nextcloud 33.0.5 - Create: Nextcloud 33.0.6 - Repos: 1 - -Processing nextcloud/server... - Moved #100 → 'Nextcloud 33.0.5' - Moved #101 → 'Nextcloud 33.0.5' - Closed milestone 'Nextcloud 33.0.4' (#10) - Created milestone 'Nextcloud 33.0.6' - -=== Summary === - Milestones closed: 1 - Milestones created: 1 - Issues moved: 2 diff --git a/tests/milestone-scripts/scenarios/patch-release/fixture.json b/tests/milestone-scripts/scenarios/patch-release/fixture.json deleted file mode 100644 index b878b7a4917..00000000000 --- a/tests/milestone-scripts/scenarios/patch-release/fixture.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "milestones": { - "nextcloud/server": [ - {"number":10,"title":"Nextcloud 33.0.4","state":"open","open_issues":2,"due_on":null}, - {"number":11,"title":"Nextcloud 33.0.5","state":"open","open_issues":0,"due_on":null} - ] - }, - "issues": { - "nextcloud/server": [ - {"number":100,"milestone":10,"state":"open"}, - {"number":101,"milestone":10,"state":"open"} - ] - }, - "tags": [] -} diff --git a/tests/milestone-scripts/scenarios/prerelease-noop/args.env b/tests/milestone-scripts/scenarios/prerelease-noop/args.env deleted file mode 100644 index 60360e60f8b..00000000000 --- a/tests/milestone-scripts/scenarios/prerelease-noop/args.env +++ /dev/null @@ -1,2 +0,0 @@ -SCRIPT=update -TAG=v33.0.2rc1 diff --git a/tests/milestone-scripts/scenarios/prerelease-noop/expected/exit b/tests/milestone-scripts/scenarios/prerelease-noop/expected/exit deleted file mode 100644 index 573541ac970..00000000000 --- a/tests/milestone-scripts/scenarios/prerelease-noop/expected/exit +++ /dev/null @@ -1 +0,0 @@ -0 diff --git a/tests/milestone-scripts/scenarios/prerelease-noop/expected/journal.txt b/tests/milestone-scripts/scenarios/prerelease-noop/expected/journal.txt deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/milestone-scripts/scenarios/prerelease-noop/expected/stdout.txt b/tests/milestone-scripts/scenarios/prerelease-noop/expected/stdout.txt deleted file mode 100644 index 271d14d7218..00000000000 --- a/tests/milestone-scripts/scenarios/prerelease-noop/expected/stdout.txt +++ /dev/null @@ -1 +0,0 @@ -Tag v33.0.2rc1 is a pre-release but not first beta. Nothing to do. diff --git a/tests/milestone-scripts/scenarios/prerelease-noop/fixture.json b/tests/milestone-scripts/scenarios/prerelease-noop/fixture.json deleted file mode 100644 index aabcf46a2e7..00000000000 --- a/tests/milestone-scripts/scenarios/prerelease-noop/fixture.json +++ /dev/null @@ -1 +0,0 @@ -{ "milestones": { "nextcloud/server": [] }, "issues": { "nextcloud/server": [] }, "tags": [] } diff --git a/tests/milestone-scripts/unit.sh b/tests/milestone-scripts/unit.sh deleted file mode 100755 index 1dd821c97a0..00000000000 --- a/tests/milestone-scripts/unit.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors -# SPDX-License-Identifier: MIT -# -# Unit tests for update-milestones.sh: argument validation, due-date parsing, -# release-type routing, and the repo-list merge. These exercise behaviour that -# happens before (or independently of) the GitHub API, so they inject GH=true -# (a no-op gh) instead of the stateful fake-gh mock. -# -# Usage: bash unit.sh - -set -uo pipefail - -TEST_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_DIR="$(cd "$TEST_DIR/../.." && pwd)" -CONFIGS="$TEST_DIR/configs" -SCRIPT="$REPO_DIR/.github/scripts/update-milestones.sh" - -PASS=0 -FAIL=0 - -# Run the script with a no-op gh. Captures combined output in $OUT, exit in $RC. -run() { - OUT=$(GH=true bash "$SCRIPT" "$@" 2>&1) - RC=$? -} - -ok() { echo " ok: $1"; PASS=$((PASS + 1)); } -ko() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } - -assert_rc() { # desc expected actual - if [[ "$2" == "$3" ]]; then ok "$1"; else ko "$1 (exit $3, expected $2)"; fi -} -assert_contains() { # desc needle haystack - if [[ "$3" == *"$2"* ]]; then ok "$1"; else ko "$1 (missing: $2)"; fi -} -assert_rc_nonzero() { # desc actual - if [[ "$2" -ne 0 ]]; then ok "$1"; else ko "$1 (expected non-zero)"; fi -} - -echo "--- argument validation ---" -run -assert_rc_nonzero "missing all positional args fails" "$RC" - -run v33.0.4 "$CONFIGS/stable33.json" -assert_rc_nonzero "missing tag-only arg fails" "$RC" - -run v33.0.4 "$CONFIGS/stable33.json" "$CONFIGS/tag-only.json" --bogus -assert_rc "unknown flag exits 1" 1 "$RC" -assert_contains "unknown flag reports the option" "Unknown option: --bogus" "$OUT" - -echo "--- due-date parsing ---" -run v33.0.4 "$CONFIGS/stable33.json" "$CONFIGS/tag-only.json" --next-due 2026/07/23 -assert_rc "bad --next-due format exits 1" 1 "$RC" -assert_contains "bad --next-due reports error" "Invalid --next-due" "$OUT" - -run v33.0.4 "$CONFIGS/stable33.json" "$CONFIGS/tag-only.json" --upcoming-due 2026/08/27 -assert_rc "bad --upcoming-due format exits 1" 1 "$RC" -assert_contains "bad --upcoming-due reports error" "Invalid --upcoming-due" "$OUT" - -run v33.0.4 "$CONFIGS/stable33.json" "$CONFIGS/tag-only.json" --next-due 2026-07-02 --upcoming-due 2026-08-27 -assert_rc "valid due dates succeed" 0 "$RC" -assert_contains "next due echoed back" "Move issues to: Nextcloud 33.0.5 (due: 2026-07-02)" "$OUT" -assert_contains "upcoming due echoed back" "Create: Nextcloud 33.0.6 (due: 2026-08-27)" "$OUT" - -echo "--- release-type routing ---" -run v35.0.0beta1 "$CONFIGS/stable33.json" "$CONFIGS/tag-only.json" -assert_contains "first beta routes to major-milestone creation" "First beta detected" "$OUT" - -run v33.0.4 "$CONFIGS/stable33.json" "$CONFIGS/tag-only.json" -assert_contains "stable routes to close/create/move" "Stable release detected" "$OUT" - -run v33.0.2rc1 "$CONFIGS/stable33.json" "$CONFIGS/tag-only.json" -assert_rc "non-first-beta pre-release is a no-op (exit 0)" 0 "$RC" -assert_contains "non-first-beta pre-release does nothing" "Nothing to do" "$OUT" - -echo "--- repo-list merge (object config + string tag-only, deduped) ---" -run v33.0.4 "$CONFIGS/unit-config.json" "$CONFIGS/unit-tagonly.json" -assert_contains "merges both config formats into a sorted unique union of 3" "Repos: 3" "$OUT" - -echo "" -echo "=== Unit results: ${PASS} passed, ${FAIL} failed ===" -[[ $FAIL -eq 0 ]] diff --git a/tools/release/README.md b/tools/release/README.md index 1939c1ad076..704f4a89d22 100644 --- a/tools/release/README.md +++ b/tools/release/README.md @@ -4,29 +4,100 @@ --> # Release tools (PHP) -Unit-tested PHP for the release automation, migrated incrementally from the -bash scripts in `.github/scripts/`. The logic-heavy, API-driven scripts move -here where they get real types, return values and PHPUnit coverage; the -filesystem/packaging scripts stay in bash. +Unit-tested PHP for the GitHub-API parts of the release automation, migrated +from the bash scripts in `.github/scripts/`. The logic-heavy, API-driven steps +(milestones, tagging) live here where they get real types, return values and +PHPUnit coverage. The filesystem/packaging scripts stay in bash. -## Layout +## Commands -- `src/` — domain logic, pure (no I/O), mockable services, and (later) commands. -- `tests/` — PHPUnit. +```bash +cd tools/release && composer install +GH_TOKEN=... php bin/console +``` + +| command | replaces | what | +|---|---|---| +| `milestones:update [--dry-run] [--next-due Y-M-D] [--upcoming-due Y-M-D]` | `update-milestones.sh` | close/create milestones, move issues | +| `milestones:audit ` | `audit-milestones.sh` | report milestone inconsistencies (read-only) | +| `repo:tag [--force] [--dry-run]` | `tag-repo.sh` | tag all release repos at a branch | + +`GH_TOKEN` (or `GITHUB_TOKEN`) must be a token with write access to the org +repos - the same `RELEASE_TOKEN` the workflows already pass. + +## Release auto-logic + +This is the behaviour the workflows encode. It is intentionally rule-based so a +human rarely has to think about milestone/tag bookkeeping. + +### Config & branch selection (from the tag) + +- **Alpha/beta of a new major** (`vN.0.0alpha*` / `vN.0.0beta*`) -> branch + `master`, config `master.json`. +- **Everything else** (stable `vN.M.P`, and RCs) -> branch `stableN`, config + `stableN.json`. +- The repo list is the **union** of the config file (objects with `.repo`) and + `tag-only.json` (plain strings), sorted and de-duplicated. + +### Milestones (`milestones:update`) + +The invariant: **two patch milestones stay open** at all times. + +- **Stable release `vX.Y.Z`** (not a pre-release): + 1. find the released milestone - `Nextcloud X.Y.Z`, or the short `Nextcloud X` + for an initial `X.0.0`; + 2. ensure `Nextcloud X.Y.(Z+1)` exists (create if missing) - the **next** patch; + 3. **move all open issues** from the released milestone to the next one + (gathered up front, so none are skipped; pull requests are left alone); + 4. **close** the released milestone; + 5. ensure `Nextcloud X.Y.(Z+2)` exists (create if missing) - the **upcoming** patch. + - `--next-due` / `--upcoming-due` set the due date on the next / upcoming + milestone respectively. The date is applied whether the milestone is created + now **or already exists**, so re-running fixes stale/missing dates and is + idempotent. +- **First beta `vN.0.0beta1`**: only **create `Nextcloud N+1`** (the next major) + where missing. `Nextcloud N` already exists from the previous cycle. No close, + no move, no due date. +- **Any other pre-release** (alpha/rc, later betas): **no-op**. +- `--dry-run` reads state and logs "Would ..." but changes nothing. + +### Audit (`milestones:audit`) + +Read-only. Derives the major from the config name (`stableN` -> N; `master` -> +highest released major + 1), finds the latest stable release of that major, and +flags: released milestone still open, missing/closed next or upcoming, an +upcoming milestone without a due date, and orphaned patch milestones (open but at +or below the released patch). Exits non-zero if anything is flagged. If no stable +release exists yet for the major, it skips. + +### Tags (`repo:tag`) + +- Per repo, resolve the tag target: prefer the release `branch` (stableN/master), + fall back to the repo's **default branch**; fail that repo if neither exists. +- Create the tag as a **lightweight ref** via the git-refs API (same result as + the old `gh release create`, no clone/push). +- If the tag already exists: **skip**, unless `--force` (then recreate) - but the + **server repos are immutable**: `nextcloud/server` and + `nextcloud-releases/server` are never re-tagged even with `--force`. +- `--dry-run` reports what it would do without writing. + +## Architecture & testing -This first step ships the milestone **domain** (no I/O): +Three layers, so the logic is testable without touching GitHub: -| class | purpose | -|---|---| -| `Version` | parse a release tag → major/minor/patch, isPrerelease, isFirstBeta | -| `MilestonePlan` | current/next/upcoming milestone names; first beta → next major (N+1) | -| `DueDate` | validate `YYYY-MM-DD`, convert to the ISO form the API wants | -| `RepoList` | merge the config + tag-only lists (sorted, unique) | -| `AuditExpectation` | expected milestone state + orphan rule for the audit | +- **Domain** (`src/Version.php`, `MilestonePlan`, `DueDate`, `RepoList`, + `AuditExpectation`, `TagPolicy`) - pure, no I/O. +- **Service** (`MilestoneUpdater`, `MilestoneAuditor`, `RepoTagger`) - the + orchestration, talking to a `GitHub\GitHubApi` interface. +- **Command** (`src/Command/`) - thin Symfony Console glue; builds the real + `GitHub\KnpGitHubApi` (knplabs/github-api) from `GH_TOKEN`. -Next steps wire these behind a GitHub client wrapper and Symfony Console -commands (`milestones:update`, `milestones:audit`), then retire the -corresponding bash scripts once at parity. +Tests run the services against `GitHub\FakeGitHubApi` - an in-memory GitHub that +records every mutation to a **journal**, so a test asserts the exact sequence of +create/close/move/setdue/tag calls (the PHP equivalent of the old +`fake-gh.sh` + journal). Only the thin `KnpGitHubApi` adapter touches the +network; it's verified by dry-running the workflows. Each test file documents +what it covers and why in its class docblock. ## Develop diff --git a/tools/release/bin/console b/tools/release/bin/console new file mode 100755 index 00000000000..ddd60020002 --- /dev/null +++ b/tools/release/bin/console @@ -0,0 +1,20 @@ +#!/usr/bin/env php +add(new MilestonesUpdateCommand()); +$app->add(new MilestonesAuditCommand()); +$app->add(new RepoTagCommand()); +$app->run(); diff --git a/tools/release/composer.json b/tools/release/composer.json index 1861677a24a..2023ff62637 100644 --- a/tools/release/composer.json +++ b/tools/release/composer.json @@ -4,7 +4,10 @@ "license": "MIT", "type": "project", "require": { - "php": ">=8.3" + "php": ">=8.3", + "guzzlehttp/guzzle": "^7.4", + "knplabs/github-api": "^3.8", + "symfony/console": "^7" }, "require-dev": { "phpunit/phpunit": "^12" diff --git a/tools/release/composer.lock b/tools/release/composer.lock index 02b220284a1..12128c6f627 100644 --- a/tools/release/composer.lock +++ b/tools/release/composer.lock @@ -4,8 +4,2071 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4440aa21f4c1de239a32e9e1840356e6", - "packages": [], + "content-hash": "a62d123a30569f588ad68c02316f30cf", + "packages": [ + { + "name": "clue/stream-filter", + "version": "v1.7.0", + "source": { + "type": "git", + "url": "https://github.com/clue/stream-filter.git", + "reference": "049509fef80032cb3f051595029ab75b49a3c2f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/stream-filter/zipball/049509fef80032cb3f051595029ab75b49a3c2f7", + "reference": "049509fef80032cb3f051595029ab75b49a3c2f7", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "Clue\\StreamFilter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "A simple and modern approach to stream filtering in PHP", + "homepage": "https://github.com/clue/stream-filter", + "keywords": [ + "bucket brigade", + "callback", + "filter", + "php_user_filter", + "stream", + "stream_filter_append", + "stream_filter_register" + ], + "support": { + "issues": "https://github.com/clue/stream-filter/issues", + "source": "https://github.com/clue/stream-filter/tree/v1.7.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2023-12-20T15:40:13+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.11.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/5af96f374e0ab4ebd747b8310888c99d3adb0a8c", + "reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.5", + "guzzlehttp/psr7": "^2.11", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.24" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "guzzlehttp/test-server": "^0.5", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.11.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-06-07T22:54:06+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.5.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.5.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-06-02T12:23:43+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.11.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/bbb5e61349fa5cb822b3e87842b951088b76b81f", + "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.24" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.11.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-06-02T12:30:48+00:00" + }, + { + "name": "knplabs/github-api", + "version": "v3.16.0", + "source": { + "type": "git", + "url": "https://github.com/KnpLabs/php-github-api.git", + "reference": "25d7bafd6b0dd088d4850aef7fcc74dc4fba8b28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/KnpLabs/php-github-api/zipball/25d7bafd6b0dd088d4850aef7fcc74dc4fba8b28", + "reference": "25d7bafd6b0dd088d4850aef7fcc74dc4fba8b28", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.2.5 || ^8.0", + "php-http/cache-plugin": "^1.7.1|^2.0", + "php-http/client-common": "^2.3", + "php-http/discovery": "^1.12", + "php-http/httplug": "^2.2", + "php-http/multipart-stream-builder": "^1.1.2", + "psr/cache": "^1.0|^2.0|^3.0", + "psr/http-client-implementation": "^1.0", + "psr/http-factory-implementation": "^1.0", + "psr/http-message": "^1.0|^2.0", + "symfony/deprecation-contracts": "^2.2|^3.0", + "symfony/polyfill-php80": "^1.17" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.2", + "guzzlehttp/psr7": "^2.7", + "http-interop/http-factory-guzzle": "^1.0", + "php-http/mock-client": "^1.4.1", + "phpstan/extension-installer": "^1.0.5", + "phpstan/phpstan": "^0.12.57", + "phpstan/phpstan-deprecation-rules": "^0.12.5", + "phpunit/phpunit": "^8.5 || ^9.4", + "symfony/cache": "^5.1.8", + "symfony/phpunit-bridge": "^5.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.20.x-dev", + "dev-master": "3.15-dev" + } + }, + "autoload": { + "psr-4": { + "Github\\": "lib/Github/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KnpLabs Team", + "homepage": "http://knplabs.com" + }, + { + "name": "Thibault Duplessis", + "email": "thibault.duplessis@gmail.com", + "homepage": "http://ornicar.github.com" + } + ], + "description": "GitHub API v3 client", + "homepage": "https://github.com/KnpLabs/php-github-api", + "keywords": [ + "api", + "gh", + "gist", + "github" + ], + "support": { + "issues": "https://github.com/KnpLabs/php-github-api/issues", + "source": "https://github.com/KnpLabs/php-github-api/tree/v3.16.0" + }, + "funding": [ + { + "url": "https://github.com/acrobat", + "type": "github" + } + ], + "time": "2024-11-07T19:35:30+00:00" + }, + { + "name": "php-http/cache-plugin", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/cache-plugin.git", + "reference": "1443d3882c4c18556fe9b409f664ebc8c021940c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/cache-plugin/zipball/1443d3882c4c18556fe9b409f664ebc8c021940c", + "reference": "1443d3882c4c18556fe9b409f664ebc8c021940c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/client-common": "^1.9 || ^2.0", + "psr/cache": "^1.0 || ^2.0 || ^3.0", + "psr/http-factory-implementation": "^1.0", + "symfony/options-resolver": "^2.6 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "nyholm/psr7": "^1.6.1", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Client\\Common\\Plugin\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "PSR-6 Cache plugin for HTTPlug", + "homepage": "http://httplug.io", + "keywords": [ + "cache", + "http", + "httplug", + "plugin" + ], + "support": { + "issues": "https://github.com/php-http/cache-plugin/issues", + "source": "https://github.com/php-http/cache-plugin/tree/2.1.0" + }, + "time": "2026-05-12T09:44:11+00:00" + }, + { + "name": "php-http/client-common", + "version": "2.7.3", + "source": { + "type": "git", + "url": "https://github.com/php-http/client-common.git", + "reference": "dcc6de29c90dd74faab55f71b79d89409c4bf0c1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/client-common/zipball/dcc6de29c90dd74faab55f71b79d89409c4bf0c1", + "reference": "dcc6de29c90dd74faab55f71b79d89409c4bf0c1", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/httplug": "^2.0", + "php-http/message": "^1.6", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0 || ^2.0", + "symfony/options-resolver": "~4.0.15 || ~4.1.9 || ^4.2.1 || ^5.0 || ^6.0 || ^7.0 || ^8.0", + "symfony/polyfill-php80": "^1.17" + }, + "require-dev": { + "doctrine/instantiator": "^1.1", + "guzzlehttp/psr7": "^1.4", + "nyholm/psr7": "^1.2", + "phpunit/phpunit": "^7.5.20 || ^8.5.33 || ^9.6.7" + }, + "suggest": { + "ext-json": "To detect JSON responses with the ContentTypePlugin", + "ext-libxml": "To detect XML responses with the ContentTypePlugin", + "php-http/cache-plugin": "PSR-6 Cache plugin", + "php-http/logger-plugin": "PSR-3 Logger plugin", + "php-http/stopwatch-plugin": "Symfony Stopwatch plugin" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Client\\Common\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Common HTTP Client implementations and tools for HTTPlug", + "homepage": "http://httplug.io", + "keywords": [ + "client", + "common", + "http", + "httplug" + ], + "support": { + "issues": "https://github.com/php-http/client-common/issues", + "source": "https://github.com/php-http/client-common/tree/2.7.3" + }, + "time": "2025-11-29T19:12:34+00:00" + }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, + { + "name": "php-http/httplug", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/php-http/httplug.git", + "reference": "5cad731844891a4c282f3f3e1b582c46839d22f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/httplug/zipball/5cad731844891a4c282f3f3e1b582c46839d22f4", + "reference": "5cad731844891a4c282f3f3e1b582c46839d22f4", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/promise": "^1.1", + "psr/http-client": "^1.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.1 || ^5.0 || ^6.0", + "phpspec/phpspec": "^5.1 || ^6.0 || ^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eric GELOEN", + "email": "geloen.eric@gmail.com" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "HTTPlug, the HTTP client abstraction for PHP", + "homepage": "http://httplug.io", + "keywords": [ + "client", + "http" + ], + "support": { + "issues": "https://github.com/php-http/httplug/issues", + "source": "https://github.com/php-http/httplug/tree/2.4.1" + }, + "time": "2024-09-23T11:39:58+00:00" + }, + { + "name": "php-http/message", + "version": "1.16.2", + "source": { + "type": "git", + "url": "https://github.com/php-http/message.git", + "reference": "06dd5e8562f84e641bf929bfe699ee0f5ce8080a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/message/zipball/06dd5e8562f84e641bf929bfe699ee0f5ce8080a", + "reference": "06dd5e8562f84e641bf929bfe699ee0f5ce8080a", + "shasum": "" + }, + "require": { + "clue/stream-filter": "^1.5", + "php": "^7.2 || ^8.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.6", + "ext-zlib": "*", + "guzzlehttp/psr7": "^1.0 || ^2.0", + "laminas/laminas-diactoros": "^2.0 || ^3.0", + "php-http/message-factory": "^1.0.2", + "phpspec/phpspec": "^5.1 || ^6.3 || ^7.1", + "slim/slim": "^3.0" + }, + "suggest": { + "ext-zlib": "Used with compressor/decompressor streams", + "guzzlehttp/psr7": "Used with Guzzle PSR-7 Factories", + "laminas/laminas-diactoros": "Used with Diactoros Factories", + "slim/slim": "Used with Slim Framework PSR-7 implementation" + }, + "type": "library", + "autoload": { + "files": [ + "src/filters.php" + ], + "psr-4": { + "Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "HTTP Message related tools", + "homepage": "http://php-http.org", + "keywords": [ + "http", + "message", + "psr-7" + ], + "support": { + "issues": "https://github.com/php-http/message/issues", + "source": "https://github.com/php-http/message/tree/1.16.2" + }, + "time": "2024-10-02T11:34:13+00:00" + }, + { + "name": "php-http/multipart-stream-builder", + "version": "1.4.2", + "source": { + "type": "git", + "url": "https://github.com/php-http/multipart-stream-builder.git", + "reference": "10086e6de6f53489cca5ecc45b6f468604d3460e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/multipart-stream-builder/zipball/10086e6de6f53489cca5ecc45b6f468604d3460e", + "reference": "10086e6de6f53489cca5ecc45b6f468604d3460e", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/discovery": "^1.15", + "psr/http-factory-implementation": "^1.0" + }, + "require-dev": { + "nyholm/psr7": "^1.0", + "php-http/message": "^1.5", + "php-http/message-factory": "^1.0.2", + "phpunit/phpunit": "^7.5.15 || ^8.5 || ^9.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Message\\MultipartStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + } + ], + "description": "A builder class that help you create a multipart stream", + "homepage": "http://php-http.org", + "keywords": [ + "factory", + "http", + "message", + "multipart stream", + "stream" + ], + "support": { + "issues": "https://github.com/php-http/multipart-stream-builder/issues", + "source": "https://github.com/php-http/multipart-stream-builder/tree/1.4.2" + }, + "time": "2024-09-04T13:22:54+00:00" + }, + { + "name": "php-http/promise", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/php-http/promise.git", + "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/promise/zipball/fc85b1fba37c169a69a07ef0d5a8075770cc1f83", + "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.3.2 || ^6.3", + "phpspec/phpspec": "^5.1.2 || ^6.2 || ^7.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joel Wurtz", + "email": "joel.wurtz@gmail.com" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Promise used for asynchronous HTTP requests", + "homepage": "http://httplug.io", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/php-http/promise/issues", + "source": "https://github.com/php-http/promise/tree/1.3.1" + }, + "time": "2024-03-15T13:55:21+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217", + "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T08:56:14+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-13T15:52:40+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "88f9c561f678a02d54b897014049fa839e33ff82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82", + "reference": "88f9c561f678a02d54b897014049fa839e33ff82", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T05:58:03+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:48:31+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-28T09:44:51+00:00" + }, + { + "name": "symfony/string", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + } + ], "packages-dev": [ { "name": "myclabs/deep-copy", diff --git a/tools/release/src/Command/MilestonesAuditCommand.php b/tools/release/src/Command/MilestonesAuditCommand.php new file mode 100644 index 00000000000..b69f853a488 --- /dev/null +++ b/tools/release/src/Command/MilestonesAuditCommand.php @@ -0,0 +1,67 @@ +addArgument('config', InputArgument::REQUIRED, 'Config file (stableXX.json / master.json)') + ->addArgument('tag-only', InputArgument::REQUIRED, 'tag-only.json path'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $config = (string) $input->getArgument('config'); + $api = KnpGitHubApi::withToken(self::token()); + + $serverTags = $api->listTagNames(self::SERVER_REPO); + $major = ReleaseConfig::majorFromConfigBasename(basename($config, '.json'), $serverTags); + $latest = ReleaseConfig::latestStable($major, $serverTags); + if ($latest === null) { + $output->writeln("No stable release found for major {$major}. Skipping audit."); + return Command::SUCCESS; + } + + $repos = ReleaseConfig::repos($config, (string) $input->getArgument('tag-only')); + $warnings = (new MilestoneAuditor($api))->audit($latest, $repos); + + $output->writeln("=== Milestone audit for Nextcloud {$major} (latest stable {$latest->major}.{$latest->minor}.{$latest->patch}) ==="); + if ($warnings === []) { + $output->writeln('OK - no issues found'); + return Command::SUCCESS; + } + foreach ($warnings as $w) { + $output->writeln("::warning::{$w}"); + } + $output->writeln(sprintf('%d issue(s) found', count($warnings))); + return Command::FAILURE; + } + + private static function token(): string + { + $token = getenv('GH_TOKEN') ?: getenv('GITHUB_TOKEN'); + if ($token === false || $token === '') { + throw new \RuntimeException('GH_TOKEN (or GITHUB_TOKEN) is required'); + } + return $token; + } +} diff --git a/tools/release/src/Command/MilestonesUpdateCommand.php b/tools/release/src/Command/MilestonesUpdateCommand.php new file mode 100644 index 00000000000..bd6ecae87d6 --- /dev/null +++ b/tools/release/src/Command/MilestonesUpdateCommand.php @@ -0,0 +1,72 @@ +addArgument('tag', InputArgument::REQUIRED, 'Release tag, e.g. v34.0.4 or v35.0.0beta1') + ->addArgument('config', InputArgument::REQUIRED, 'Config file (stableXX.json / master.json)') + ->addArgument('tag-only', InputArgument::REQUIRED, 'tag-only.json path') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Preview without changing anything') + ->addOption('next-due', null, InputOption::VALUE_REQUIRED, 'Due date (YYYY-MM-DD) for the next patch milestone') + ->addOption('upcoming-due', null, InputOption::VALUE_REQUIRED, 'Due date (YYYY-MM-DD) for the upcoming patch milestone'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $version = Version::fromTag((string) $input->getArgument('tag')); + $repos = ReleaseConfig::repos((string) $input->getArgument('config'), (string) $input->getArgument('tag-only')); + + $nextDue = $input->getOption('next-due'); + $upcomingDue = $input->getOption('upcoming-due'); + $nextDueOn = $nextDue !== null ? DueDate::toIso((string) $nextDue) : null; + $upcomingDueOn = $upcomingDue !== null ? DueDate::toIso((string) $upcomingDue) : null; + + $dryRun = (bool) $input->getOption('dry-run'); + $api = KnpGitHubApi::withToken(self::token()); + $updater = new MilestoneUpdater($api, $dryRun); + $updater->run($version, $repos, $nextDueOn, $upcomingDueOn); + + foreach ($updater->log as $line) { + $output->writeln($line); + } + $output->writeln(sprintf( + '%s closed=%d created=%d issues-moved=%d', + $dryRun ? '[dry-run]' : 'Done:', + $updater->closed, + $updater->created, + $updater->moved, + )); + return Command::SUCCESS; + } + + private static function token(): string + { + $token = getenv('GH_TOKEN') ?: getenv('GITHUB_TOKEN'); + if ($token === false || $token === '') { + throw new \RuntimeException('GH_TOKEN (or GITHUB_TOKEN) is required'); + } + return $token; + } +} diff --git a/tools/release/src/Command/RepoTagCommand.php b/tools/release/src/Command/RepoTagCommand.php new file mode 100644 index 00000000000..c49a67757bf --- /dev/null +++ b/tools/release/src/Command/RepoTagCommand.php @@ -0,0 +1,69 @@ +addArgument('tag', InputArgument::REQUIRED, 'Tag to create, e.g. v34.0.1') + ->addArgument('branch', InputArgument::REQUIRED, 'Release branch (stableXX/master); falls back to each repo default') + ->addArgument('config', InputArgument::REQUIRED, 'Config file (stableXX.json / master.json)') + ->addArgument('tag-only', InputArgument::REQUIRED, 'tag-only.json path') + ->addOption('force', null, InputOption::VALUE_NONE, 'Recreate existing tags (never on the server repos)') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Preview without changing anything'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $tag = (string) $input->getArgument('tag'); + $branch = (string) $input->getArgument('branch'); + $repos = ReleaseConfig::repos((string) $input->getArgument('config'), (string) $input->getArgument('tag-only')); + $force = (bool) $input->getOption('force'); + $dryRun = (bool) $input->getOption('dry-run'); + + $tagger = new RepoTagger(KnpGitHubApi::withToken(self::token()), $dryRun); + + $ok = 0; + $skipped = 0; + $failed = 0; + foreach ($repos as $repo) { + $r = $tagger->tag($repo, $branch, $tag, $force); + $output->writeln(sprintf('%-40s %-10s %s (%s)', $r->repo, $r->status, $r->branch, $r->detail)); + match ($r->status) { + 'OK' => $ok++, + 'SKIPPED' => $skipped++, + default => $failed++, + }; + } + + $output->writeln(sprintf('%sTagged: %d | Skipped: %d | Failed: %d', $dryRun ? '[dry-run] ' : '', $ok, $skipped, $failed)); + return $failed > 0 ? Command::FAILURE : Command::SUCCESS; + } + + private static function token(): string + { + $token = getenv('GH_TOKEN') ?: getenv('GITHUB_TOKEN'); + if ($token === false || $token === '') { + throw new \RuntimeException('GH_TOKEN (or GITHUB_TOKEN) is required'); + } + return $token; + } +} diff --git a/tools/release/src/GitHub/FakeGitHubApi.php b/tools/release/src/GitHub/FakeGitHubApi.php new file mode 100644 index 00000000000..96b63fcb960 --- /dev/null +++ b/tools/release/src/GitHub/FakeGitHubApi.php @@ -0,0 +1,133 @@ +> repo => number => milestone */ + private array $milestones = []; + /** @var array> repo => issue number => milestone number */ + private array $issues = []; + /** @var array> repo => tag => sha */ + private array $tags = []; + /** @var array repo => default branch */ + private array $defaultBranches = []; + /** @var array> repo => branch => sha */ + private array $branches = []; + + /** @var list ordered mutation log */ + public array $journal = []; + + private int $nextNumber = 1; + + public function seedMilestone(string $repo, int $number, string $title, string $state = 'open', int $openIssues = 0, ?string $dueOn = null): void + { + $this->milestones[$repo][$number] = new Milestone($number, $title, $state, $openIssues, $dueOn); + $this->nextNumber = max($this->nextNumber, $number + 1); + } + + public function seedIssue(string $repo, int $number, int $milestoneNumber): void + { + $this->issues[$repo][$number] = $milestoneNumber; + } + + public function seedTag(string $repo, string $tag, string $sha = 'sha-' . '0000'): void + { + $this->tags[$repo][$tag] = $sha; + } + + public function seedBranch(string $repo, string $branch, string $sha, bool $default = false): void + { + $this->branches[$repo][$branch] = $sha; + if ($default) { + $this->defaultBranches[$repo] = $branch; + } + } + + public function listMilestones(string $repo): array + { + return array_values($this->milestones[$repo] ?? []); + } + + public function createMilestone(string $repo, string $title, ?string $dueOn): int + { + $number = $this->nextNumber++; + $this->milestones[$repo][$number] = new Milestone($number, $title, 'open', 0, $dueOn); + $this->journal[] = sprintf("create\t%s\t%s\tdue=%s", $repo, $title, $dueOn ?? '-'); + return $number; + } + + public function closeMilestone(string $repo, int $number): void + { + $m = $this->milestones[$repo][$number]; + $this->milestones[$repo][$number] = new Milestone($m->number, $m->title, 'closed', $m->openIssues, $m->dueOn); + $this->journal[] = sprintf("close\t%s\t%d", $repo, $number); + } + + public function setMilestoneDue(string $repo, int $number, string $dueOn): void + { + $m = $this->milestones[$repo][$number]; + $this->milestones[$repo][$number] = new Milestone($m->number, $m->title, $m->state, $m->openIssues, $dueOn); + $this->journal[] = sprintf("setdue\t%s\t%d\t%s", $repo, $number, $dueOn); + } + + public function openIssueNumbers(string $repo, int $milestoneNumber): array + { + $out = []; + foreach ($this->issues[$repo] ?? [] as $issue => $ms) { + if ($ms === $milestoneNumber) { + $out[] = $issue; + } + } + sort($out); + return $out; + } + + public function moveIssue(string $repo, int $issueNumber, int $milestoneNumber): void + { + $this->issues[$repo][$issueNumber] = $milestoneNumber; + $this->journal[] = sprintf("move\t%s\t%d\t%d", $repo, $issueNumber, $milestoneNumber); + } + + public function listTagNames(string $repo): array + { + return array_keys($this->tags[$repo] ?? []); + } + + public function branchSha(string $repo, string $branch): ?string + { + return $this->branches[$repo][$branch] ?? null; + } + + public function defaultBranch(string $repo): ?string + { + return $this->defaultBranches[$repo] ?? null; + } + + public function tagSha(string $repo, string $tag): ?string + { + return $this->tags[$repo][$tag] ?? null; + } + + public function createTag(string $repo, string $tag, string $sha): void + { + $this->tags[$repo][$tag] = $sha; + $this->journal[] = sprintf("tag\t%s\t%s\t%s", $repo, $tag, $sha); + } + + public function updateTag(string $repo, string $tag, string $sha, bool $force): void + { + $this->tags[$repo][$tag] = $sha; + $this->journal[] = sprintf("retag\t%s\t%s\t%s\tforce=%s", $repo, $tag, $sha, $force ? 'true' : 'false'); + } +} diff --git a/tools/release/src/GitHub/GitHubApi.php b/tools/release/src/GitHub/GitHubApi.php new file mode 100644 index 00000000000..0bd07204c21 --- /dev/null +++ b/tools/release/src/GitHub/GitHubApi.php @@ -0,0 +1,48 @@ + every milestone (any state) */ + public function listMilestones(string $repo): array; + + /** @return int the new milestone number */ + public function createMilestone(string $repo, string $title, ?string $dueOn): int; + + public function closeMilestone(string $repo, int $number): void; + + public function setMilestoneDue(string $repo, int $number, string $dueOn): void; + + /** @return list open issue numbers in a milestone (all pages) */ + public function openIssueNumbers(string $repo, int $milestoneNumber): array; + + public function moveIssue(string $repo, int $issueNumber, int $milestoneNumber): void; + + /** @return list tag names (e.g. "v34.0.1") */ + public function listTagNames(string $repo): array; + + /** Commit SHA at the tip of a branch, or null if the branch is absent. */ + public function branchSha(string $repo, string $branch): ?string; + + /** The repo's default branch, or null if it can't be determined. */ + public function defaultBranch(string $repo): ?string; + + /** SHA the tag points at, or null if the tag does not exist. */ + public function tagSha(string $repo, string $tag): ?string; + + public function createTag(string $repo, string $tag, string $sha): void; + + /** Move an existing tag; $force allows non-fast-forward replacement. */ + public function updateTag(string $repo, string $tag, string $sha, bool $force): void; +} diff --git a/tools/release/src/GitHub/KnpGitHubApi.php b/tools/release/src/GitHub/KnpGitHubApi.php new file mode 100644 index 00000000000..f33d8dc53cd --- /dev/null +++ b/tools/release/src/GitHub/KnpGitHubApi.php @@ -0,0 +1,157 @@ +100 milestones/issues) are fully covered. Tags are created as + * lightweight refs via the git-refs API - no clone/push. + */ +final class KnpGitHubApi implements GitHubApi +{ + public function __construct( + private readonly Client $client, + ) { + } + + public static function withToken(string $token): self + { + $client = new Client(); + $client->authenticate($token, null, Client::AUTH_ACCESS_TOKEN); + return new self($client); + } + + public function listMilestones(string $repo): array + { + [$owner, $name] = $this->split($repo); + $pager = new ResultPager($this->client); + $rows = $pager->fetchAll($this->client->api('issue')->milestones(), 'all', [$owner, $name, ['state' => 'all', 'per_page' => 100]]); + return array_map( + static fn (array $m): Milestone => new Milestone( + (int) $m['number'], + (string) $m['title'], + (string) $m['state'], + (int) ($m['open_issues'] ?? 0), + $m['due_on'] ?? null, + ), + $rows, + ); + } + + public function createMilestone(string $repo, string $title, ?string $dueOn): int + { + [$owner, $name] = $this->split($repo); + $params = ['title' => $title]; + if ($dueOn !== null) { + $params['due_on'] = $dueOn; + } + $res = $this->client->api('issue')->milestones()->create($owner, $name, $params); + return (int) $res['number']; + } + + public function closeMilestone(string $repo, int $number): void + { + [$owner, $name] = $this->split($repo); + $this->client->api('issue')->milestones()->update($owner, $name, $number, ['state' => 'closed']); + } + + public function setMilestoneDue(string $repo, int $number, string $dueOn): void + { + [$owner, $name] = $this->split($repo); + $this->client->api('issue')->milestones()->update($owner, $name, $number, ['due_on' => $dueOn]); + } + + public function openIssueNumbers(string $repo, int $milestoneNumber): array + { + [$owner, $name] = $this->split($repo); + $pager = new ResultPager($this->client); + $rows = $pager->fetchAll($this->client->api('issue'), 'all', [$owner, $name, ['milestone' => $milestoneNumber, 'state' => 'open', 'per_page' => 100]]); + $numbers = []; + foreach ($rows as $row) { + // The issues endpoint also returns pull requests; only move issues. + if (isset($row['pull_request'])) { + continue; + } + $numbers[] = (int) $row['number']; + } + return $numbers; + } + + public function moveIssue(string $repo, int $issueNumber, int $milestoneNumber): void + { + [$owner, $name] = $this->split($repo); + $this->client->api('issue')->update($owner, $name, $issueNumber, ['milestone' => $milestoneNumber]); + } + + public function listTagNames(string $repo): array + { + [$owner, $name] = $this->split($repo); + $pager = new ResultPager($this->client); + $rows = $pager->fetchAll($this->client->api('repo'), 'tags', [$owner, $name]); + return array_map(static fn (array $t): string => (string) $t['name'], $rows); + } + + public function branchSha(string $repo, string $branch): ?string + { + return $this->refSha($repo, "heads/{$branch}"); + } + + public function defaultBranch(string $repo): ?string + { + [$owner, $name] = $this->split($repo); + try { + $info = $this->client->api('repo')->show($owner, $name); + return $info['default_branch'] ?? null; + } catch (RuntimeException) { + return null; + } + } + + public function tagSha(string $repo, string $tag): ?string + { + return $this->refSha($repo, "tags/{$tag}"); + } + + public function createTag(string $repo, string $tag, string $sha): void + { + [$owner, $name] = $this->split($repo); + $this->client->api('gitData')->references()->create($owner, $name, ['ref' => "refs/tags/{$tag}", 'sha' => $sha]); + } + + public function updateTag(string $repo, string $tag, string $sha, bool $force): void + { + [$owner, $name] = $this->split($repo); + $this->client->api('gitData')->references()->update($owner, $name, "tags/{$tag}", ['sha' => $sha, 'force' => $force]); + } + + private function refSha(string $repo, string $ref): ?string + { + [$owner, $name] = $this->split($repo); + try { + $data = $this->client->api('gitData')->references()->show($owner, $name, $ref); + return $data['object']['sha'] ?? null; + } catch (RuntimeException) { + return null; + } + } + + /** @return array{0: string, 1: string} */ + private function split(string $repo): array + { + $parts = explode('/', $repo, 2); + if (count($parts) !== 2) { + throw new \InvalidArgumentException("Expected owner/name, got '{$repo}'"); + } + return [$parts[0], $parts[1]]; + } +} diff --git a/tools/release/src/GitHub/Milestone.php b/tools/release/src/GitHub/Milestone.php new file mode 100644 index 00000000000..47b16a63891 --- /dev/null +++ b/tools/release/src/GitHub/Milestone.php @@ -0,0 +1,21 @@ + $repos + * @return list warnings, one per problem (empty = all good) + */ + public function audit(Version $latestStable, array $repos): array + { + $expected = new AuditExpectation($latestStable); + $major = $latestStable->major; + $released = $expected->releasedMilestones(); + $next = $expected->nextMilestone(); + $upcoming = $expected->upcomingMilestone(); + + $warnings = []; + foreach ($repos as $repo) { + $mine = array_values(array_filter( + $this->api->listMilestones($repo), + static fn ($m) => str_starts_with($m->title, "Nextcloud {$major}"), + )); + if ($mine === []) { + $warnings[] = "{$repo}: no milestones found for Nextcloud {$major}"; + continue; + } + + $byTitle = []; + foreach ($mine as $m) { + $byTitle[$m->title] = $m; + } + + // Released milestone(s) should be closed. + foreach ($released as $title) { + $m = $byTitle[$title] ?? null; + if ($m !== null && $m->state === 'open') { + $warnings[] = "{$repo}: '{$title}' still open ({$m->openIssues} open issues) - should be closed"; + } + } + + // Next milestone should exist and be open. + $n = $byTitle[$next] ?? null; + if ($n === null) { + $warnings[] = "{$repo}: missing milestone '{$next}'"; + } elseif ($n->state !== 'open') { + $warnings[] = "{$repo}: '{$next}' is {$n->state} - should be open"; + } + + // Upcoming milestone should exist, be open, and have a due date. + $u = $byTitle[$upcoming] ?? null; + if ($u === null) { + $warnings[] = "{$repo}: missing milestone '{$upcoming}'"; + } else { + if ($u->state !== 'open') { + $warnings[] = "{$repo}: '{$upcoming}' is {$u->state} - should be open"; + } + if ($u->dueOn === null) { + $warnings[] = "{$repo}: '{$upcoming}' has no due date"; + } + } + + // Orphans: open patch milestones at or below the released version. + foreach ($mine as $m) { + if ($m->state !== 'open') { + continue; + } + if (preg_match("/^Nextcloud {$major}\\.(\\d+)\\.(\\d+)$/", $m->title, $mt) === 1 + && $expected->isOrphan((int) $mt[1], (int) $mt[2])) { + $warnings[] = "{$repo}: orphan '{$m->title}' still open ({$m->openIssues} open issues) - version already released"; + } + } + } + return $warnings; + } +} diff --git a/tools/release/src/MilestoneUpdater.php b/tools/release/src/MilestoneUpdater.php new file mode 100644 index 00000000000..63ceb4280a5 --- /dev/null +++ b/tools/release/src/MilestoneUpdater.php @@ -0,0 +1,177 @@ + */ + public array $log = []; + + public function __construct( + private readonly GitHubApi $api, + private readonly bool $dryRun = false, + ) { + } + + /** @param list $repos */ + public function run(Version $version, array $repos, ?string $nextDueOn = null, ?string $upcomingDueOn = null): void + { + if ($version->isFirstBeta) { + $this->runFirstBeta($version, $repos); + return; + } + if (!$version->isPrerelease) { + $this->runStable($version, $repos, $nextDueOn, $upcomingDueOn); + return; + } + $this->log[] = 'Pre-release (not first beta): nothing to do.'; + } + + /** @param list $repos */ + private function runFirstBeta(Version $version, array $repos): void + { + $title = MilestonePlan::firstBetaMilestone($version); + $this->log[] = "First beta: creating '{$title}' where missing."; + foreach ($repos as $repo) { + if ($this->find($repo, $title) !== null) { + $this->log[] = " {$repo}: '{$title}' already exists"; + continue; + } + $this->create($repo, $title, null); + } + } + + /** @param list $repos */ + private function runStable(Version $version, array $repos, ?string $nextDueOn, ?string $upcomingDueOn): void + { + $candidates = MilestonePlan::currentMilestones($version); + $next = MilestonePlan::nextMilestone($version); + $upcoming = MilestonePlan::upcomingMilestone($version); + + $this->log[] = sprintf( + "Stable release: close [%s], move issues to %s, keep %s open.", + implode(', ', $candidates), + $next, + $upcoming, + ); + + foreach ($repos as $repo) { + $current = $this->findFirst($repo, $candidates); + if ($current === null) { + $this->log[] = " {$repo}: no milestone " . implode('/', $candidates) . ", skipping"; + continue; + } + + // Ensure the next milestone exists and carries its due date. + $nextNumber = $this->ensure($repo, $next, $nextDueOn); + // Move all open issues before closing the current milestone. + $this->moveIssues($repo, $current->number, $nextNumber, $next); + // Close the released milestone. + $this->close($repo, $current); + // Ensure the upcoming milestone exists and carries its due date. + $this->ensure($repo, $upcoming, $upcomingDueOn); + } + } + + private function find(string $repo, string $title): ?Milestone + { + foreach ($this->api->listMilestones($repo) as $m) { + if ($m->title === $title) { + return $m; + } + } + return null; + } + + /** @param list $titles */ + private function findFirst(string $repo, array $titles): ?Milestone + { + foreach ($titles as $title) { + $m = $this->find($repo, $title); + if ($m !== null) { + return $m; + } + } + return null; + } + + /** Create $title if missing, else set its due date; returns its number (0 in dry-run when it would be created). */ + private function ensure(string $repo, string $title, ?string $dueOn): int + { + $existing = $this->find($repo, $title); + if ($existing === null) { + return $this->create($repo, $title, $dueOn); + } + if ($dueOn !== null && $existing->dueOn !== $dueOn) { + if ($this->dryRun) { + $this->log[] = " {$repo}: would set due of '{$title}' to {$dueOn}"; + } else { + $this->api->setMilestoneDue($repo, $existing->number, $dueOn); + $this->log[] = " {$repo}: set due of '{$title}' to {$dueOn}"; + } + } + return $existing->number; + } + + private function create(string $repo, string $title, ?string $dueOn): int + { + $this->created++; + if ($this->dryRun) { + $this->log[] = " {$repo}: would create '{$title}'" . ($dueOn ? " (due {$dueOn})" : ''); + return 0; + } + $number = $this->api->createMilestone($repo, $title, $dueOn); + $this->log[] = " {$repo}: created '{$title}'" . ($dueOn ? " (due {$dueOn})" : ''); + return $number; + } + + private function close(string $repo, Milestone $current): void + { + $this->closed++; + if ($this->dryRun) { + $this->log[] = " {$repo}: would close '{$current->title}'"; + return; + } + $this->api->closeMilestone($repo, $current->number); + $this->log[] = " {$repo}: closed '{$current->title}'"; + } + + private function moveIssues(string $repo, int $from, int $to, string $toTitle): void + { + // Gather all open issue numbers up front, then move them - moving while + // paginating would shift later pages and drop issues. + $issues = $this->api->openIssueNumbers($repo, $from); + foreach ($issues as $issue) { + $this->moved++; + if ($this->dryRun) { + $this->log[] = " {$repo}: would move #{$issue} -> '{$toTitle}'"; + continue; + } + $this->api->moveIssue($repo, $issue, $to); + $this->log[] = " {$repo}: moved #{$issue} -> '{$toTitle}'"; + } + } +} diff --git a/tools/release/src/ReleaseConfig.php b/tools/release/src/ReleaseConfig.php new file mode 100644 index 00000000000..676db31f853 --- /dev/null +++ b/tools/release/src/ReleaseConfig.php @@ -0,0 +1,88 @@ + + */ + public static function repos(string $configPath, string $tagOnlyPath): array + { + return RepoList::merge( + self::decode($configPath), + self::decode($tagOnlyPath), + ); + } + + /** + * Major version a config covers: "stable34" -> 34; "master" -> the highest + * released major + 1 (the one in development on master). + * + * @param list $serverTags tag names on nextcloud-releases/server + */ + public static function majorFromConfigBasename(string $basename, array $serverTags): int + { + if ($basename === 'master') { + return self::highestStableMajor($serverTags) + 1; + } + if (preg_match('/^stable(\d+)$/', $basename, $m) === 1) { + return (int) $m[1]; + } + throw new \InvalidArgumentException("Cannot derive major from config '{$basename}'"); + } + + /** + * The latest stable release for a major from the tag list, or null if there + * is none yet. + * + * @param list $tagNames + */ + public static function latestStable(int $major, array $tagNames): ?Version + { + $matching = array_values(array_filter( + $tagNames, + static fn (string $t) => preg_match("/^v{$major}\\.\\d+\\.\\d+$/", $t) === 1, + )); + if ($matching === []) { + return null; + } + usort($matching, 'version_compare'); + return Version::fromTag(end($matching)); + } + + /** @param list $tagNames */ + private static function highestStableMajor(array $tagNames): int + { + $max = 0; + foreach ($tagNames as $t) { + if (preg_match('/^v(\d+)\.\d+\.\d+$/', $t, $m) === 1) { + $max = max($max, (int) $m[1]); + } + } + return $max; + } + + /** @return list */ + private static function decode(string $path): array + { + $raw = file_get_contents($path); + if ($raw === false) { + throw new \RuntimeException("Cannot read config file: {$path}"); + } + $data = json_decode($raw, true); + return is_array($data) ? $data : []; + } +} diff --git a/tools/release/src/RepoTagger.php b/tools/release/src/RepoTagger.php new file mode 100644 index 00000000000..1d52f13f723 --- /dev/null +++ b/tools/release/src/RepoTagger.php @@ -0,0 +1,58 @@ +api->branchSha($repo, $branch); + if ($sha === null) { + $branch = $this->api->defaultBranch($repo) ?? ''; + $sha = $branch !== '' ? $this->api->branchSha($repo, $branch) : null; + } + if ($sha === null) { + return new TagResult($repo, $branch, 'FAILED', 'no branch found'); + } + + $action = TagPolicy::decide($repo, $this->api->tagSha($repo, $tag) !== null, $force); + + if ($action === TagPolicy::ACTION_SKIP) { + return new TagResult($repo, $branch, 'SKIPPED', 'already tagged'); + } + + if ($this->dryRun) { + return new TagResult($repo, $branch, 'OK', "would {$action} {$tag} @ {$sha}"); + } + + if ($action === TagPolicy::ACTION_RECREATE) { + $this->api->updateTag($repo, $tag, $sha, true); + } else { + $this->api->createTag($repo, $tag, $sha); + } + return new TagResult($repo, $branch, 'OK', "{$action}d {$tag}"); + } +} diff --git a/tools/release/src/TagPolicy.php b/tools/release/src/TagPolicy.php new file mode 100644 index 00000000000..b2382a00a44 --- /dev/null +++ b/tools/release/src/TagPolicy.php @@ -0,0 +1,48 @@ +audit(Version::fromTag('v33.0.4'), [self::REPO]); + $this->assertSame([], $api->journal, 'audit must be read-only'); + return $warnings; + } + + public function testHealthyStateHasNoWarnings(): void + { + $api = new FakeGitHubApi(); + $api->seedMilestone(self::REPO, 4, 'Nextcloud 33.0.4', 'closed'); + $api->seedMilestone(self::REPO, 5, 'Nextcloud 33.0.5', 'open'); + $api->seedMilestone(self::REPO, 6, 'Nextcloud 33.0.6', 'open', 0, '2026-08-01T00:00:00Z'); + $this->assertSame([], $this->audit($api)); + } + + public function testReleasedStillOpenWarns(): void + { + $api = new FakeGitHubApi(); + $api->seedMilestone(self::REPO, 4, 'Nextcloud 33.0.4', 'open', 1); + $api->seedMilestone(self::REPO, 5, 'Nextcloud 33.0.5', 'open'); + $api->seedMilestone(self::REPO, 6, 'Nextcloud 33.0.6', 'open', 0, '2026-08-01T00:00:00Z'); + $w = $this->audit($api); + $this->assertContains(self::REPO . ": 'Nextcloud 33.0.4' still open (1 open issues) - should be closed", $w); + } + + public function testMissingNextWarns(): void + { + $api = new FakeGitHubApi(); + $api->seedMilestone(self::REPO, 4, 'Nextcloud 33.0.4', 'closed'); + $api->seedMilestone(self::REPO, 6, 'Nextcloud 33.0.6', 'open', 0, '2026-08-01T00:00:00Z'); + $this->assertContains(self::REPO . ": missing milestone 'Nextcloud 33.0.5'", $this->audit($api)); + } + + public function testUpcomingWithoutDueWarns(): void + { + $api = new FakeGitHubApi(); + $api->seedMilestone(self::REPO, 4, 'Nextcloud 33.0.4', 'closed'); + $api->seedMilestone(self::REPO, 5, 'Nextcloud 33.0.5', 'open'); + $api->seedMilestone(self::REPO, 6, 'Nextcloud 33.0.6', 'open'); // no due + $this->assertContains(self::REPO . ": 'Nextcloud 33.0.6' has no due date", $this->audit($api)); + } + + public function testOrphanWarns(): void + { + $api = new FakeGitHubApi(); + $api->seedMilestone(self::REPO, 3, 'Nextcloud 33.0.3', 'open', 2); // older, still open + $api->seedMilestone(self::REPO, 4, 'Nextcloud 33.0.4', 'closed'); + $api->seedMilestone(self::REPO, 5, 'Nextcloud 33.0.5', 'open'); + $api->seedMilestone(self::REPO, 6, 'Nextcloud 33.0.6', 'open', 0, '2026-08-01T00:00:00Z'); + $this->assertContains(self::REPO . ": orphan 'Nextcloud 33.0.3' still open (2 open issues) - version already released", $this->audit($api)); + } + + public function testNoMilestonesWarns(): void + { + $api = new FakeGitHubApi(); + $this->assertContains(self::REPO . ': no milestones found for Nextcloud 33', $this->audit($api)); + } +} diff --git a/tools/release/tests/MilestonePlanTest.php b/tools/release/tests/MilestonePlanTest.php index f70777c6e76..f16b75bb695 100644 --- a/tools/release/tests/MilestonePlanTest.php +++ b/tools/release/tests/MilestonePlanTest.php @@ -11,6 +11,13 @@ use Nextcloud\ReleaseTools\Version; use PHPUnit\Framework\TestCase; +/** + * What: the milestone-name math - current/next/upcoming names and the + * first-beta -> next-major (N+1) rule. + * + * Why: this is exactly where the production bugs lived (first-beta off-by-one, + * the two-open-patch invariant). Plain assertions, no fixtures. + */ final class MilestonePlanTest extends TestCase { public function testStablePatchPlan(): void diff --git a/tools/release/tests/MilestoneUpdaterTest.php b/tools/release/tests/MilestoneUpdaterTest.php new file mode 100644 index 00000000000..d373f4e1060 --- /dev/null +++ b/tools/release/tests/MilestoneUpdaterTest.php @@ -0,0 +1,191 @@ +seedMilestone(self::REPO, 10, 'Nextcloud 33.0.4', 'open', 2); + $api->seedMilestone(self::REPO, 11, 'Nextcloud 33.0.5'); + $api->seedIssue(self::REPO, 100, 10); + $api->seedIssue(self::REPO, 101, 10); + + $u = new MilestoneUpdater($api); + $u->run(Version::fromTag('v33.0.4'), [self::REPO]); + + $this->assertSame([ + "move\t" . self::REPO . "\t100\t11", + "move\t" . self::REPO . "\t101\t11", + "close\t" . self::REPO . "\t10", + "create\t" . self::REPO . "\tNextcloud 33.0.6\tdue=-", + ], $api->journal); + $this->assertSame([1, 1, 2], [$u->created, $u->closed, $u->moved]); + } + + public function testInitialReleaseClosesShortNameAndCreatesNext(): void + { + $api = new FakeGitHubApi(); + // Only the short "Nextcloud 34" milestone exists; .0.1/.0.2 missing. + $api->seedMilestone(self::REPO, 20, 'Nextcloud 34'); + + $u = new MilestoneUpdater($api); + $u->run(Version::fromTag('v34.0.0'), [self::REPO]); + + $this->assertSame([ + "create\t" . self::REPO . "\tNextcloud 34.0.1\tdue=-", + "close\t" . self::REPO . "\t20", + "create\t" . self::REPO . "\tNextcloud 34.0.2\tdue=-", + ], $api->journal); + } + + public function testFirstBetaCreatesNextMajorOnly(): void + { + $api = new FakeGitHubApi(); + $u = new MilestoneUpdater($api); + $u->run(Version::fromTag('v35.0.0beta1'), [self::REPO]); + + // First beta of 35 opens 36. + $this->assertSame(["create\t" . self::REPO . "\tNextcloud 36\tdue=-"], $api->journal); + } + + public function testFirstBetaIdempotent(): void + { + $api = new FakeGitHubApi(); + $api->seedMilestone(self::REPO, 30, 'Nextcloud 36'); + $u = new MilestoneUpdater($api); + $u->run(Version::fromTag('v35.0.0beta1'), [self::REPO]); + $this->assertSame([], $api->journal); + } + + public function testMissingNextIsCreatedBeforeMoving(): void + { + $api = new FakeGitHubApi(); + $api->seedMilestone(self::REPO, 10, 'Nextcloud 33.0.4', 'open', 1); + $api->seedIssue(self::REPO, 100, 10); + + $u = new MilestoneUpdater($api); + $u->run(Version::fromTag('v33.0.4'), [self::REPO]); + + $this->assertSame([ + "create\t" . self::REPO . "\tNextcloud 33.0.5\tdue=-", + "move\t" . self::REPO . "\t100\t11", + "close\t" . self::REPO . "\t10", + "create\t" . self::REPO . "\tNextcloud 33.0.6\tdue=-", + ], $api->journal); + } + + public function testExistingUpcomingIsNotRecreated(): void + { + $api = new FakeGitHubApi(); + $api->seedMilestone(self::REPO, 10, 'Nextcloud 33.0.4', 'open', 1); + $api->seedMilestone(self::REPO, 11, 'Nextcloud 33.0.5'); + $api->seedMilestone(self::REPO, 12, 'Nextcloud 33.0.6'); + $api->seedIssue(self::REPO, 100, 10); + + $u = new MilestoneUpdater($api); + $u->run(Version::fromTag('v33.0.4'), [self::REPO]); + + $this->assertSame([ + "move\t" . self::REPO . "\t100\t11", + "close\t" . self::REPO . "\t10", + ], $api->journal); + } + + public function testDueDatesCreateAndUpdate(): void + { + $api = new FakeGitHubApi(); + $api->seedMilestone(self::REPO, 10, 'Nextcloud 33.0.4'); + $api->seedMilestone(self::REPO, 11, 'Nextcloud 33.0.5'); // exists, no due -> setdue + + $u = new MilestoneUpdater($api); + $u->run(Version::fromTag('v33.0.4'), [self::REPO], '2026-07-02T00:00:00Z', '2026-08-27T00:00:00Z'); + + $this->assertSame([ + "setdue\t" . self::REPO . "\t11\t2026-07-02T00:00:00Z", + "close\t" . self::REPO . "\t10", + "create\t" . self::REPO . "\tNextcloud 33.0.6\tdue=2026-08-27T00:00:00Z", + ], $api->journal); + } + + public function testDueDatesCorrectExistingMilestones(): void + { + $api = new FakeGitHubApi(); + $api->seedMilestone(self::REPO, 10, 'Nextcloud 33.0.4'); + $api->seedMilestone(self::REPO, 11, 'Nextcloud 33.0.5', 'open', 0, '2026-06-25T00:00:00Z'); + $api->seedMilestone(self::REPO, 12, 'Nextcloud 33.0.6'); // no due + + $u = new MilestoneUpdater($api); + $u->run(Version::fromTag('v33.0.4'), [self::REPO], '2026-07-02T00:00:00Z', '2026-08-27T00:00:00Z'); + + $this->assertSame([ + "setdue\t" . self::REPO . "\t11\t2026-07-02T00:00:00Z", + "close\t" . self::REPO . "\t10", + "setdue\t" . self::REPO . "\t12\t2026-08-27T00:00:00Z", + ], $api->journal); + } + + public function testNonFirstBetaPrereleaseIsNoop(): void + { + $api = new FakeGitHubApi(); + $api->seedMilestone(self::REPO, 10, 'Nextcloud 33.0.2'); + $u = new MilestoneUpdater($api); + $u->run(Version::fromTag('v33.0.2rc1'), [self::REPO]); + $this->assertSame([], $api->journal); + } + + public function testMovesAllIssuesNoneLeftBehind(): void + { + $api = new FakeGitHubApi(); + $api->seedMilestone(self::REPO, 10, 'Nextcloud 33.0.4', 'open', 150); + $api->seedMilestone(self::REPO, 11, 'Nextcloud 33.0.5'); + for ($i = 1; $i <= 150; $i++) { + $api->seedIssue(self::REPO, $i, 10); + } + + $u = new MilestoneUpdater($api); + $u->run(Version::fromTag('v33.0.4'), [self::REPO]); + + $this->assertSame(150, $u->moved); + $this->assertSame(150, substr_count(implode("\n", $api->journal), "move\t")); + } + + public function testDryRunChangesNothing(): void + { + $api = new FakeGitHubApi(); + $api->seedMilestone(self::REPO, 10, 'Nextcloud 33.0.4', 'open', 1); + $api->seedMilestone(self::REPO, 11, 'Nextcloud 33.0.5'); + $api->seedIssue(self::REPO, 100, 10); + + $u = new MilestoneUpdater($api, dryRun: true); + $u->run(Version::fromTag('v33.0.4'), [self::REPO]); + + $this->assertSame([], $api->journal, 'dry-run must not mutate'); + $this->assertNotEmpty($u->log); + $this->assertStringContainsString('would', strtolower(implode("\n", $u->log))); + } +} diff --git a/tools/release/tests/ReleaseConfigTest.php b/tools/release/tests/ReleaseConfigTest.php new file mode 100644 index 00000000000..93ff3660e10 --- /dev/null +++ b/tools/release/tests/ReleaseConfigTest.php @@ -0,0 +1,65 @@ + null so the audit can skip). + */ +final class ReleaseConfigTest extends TestCase +{ + public function testReposMergesFilesSortedUnique(): void + { + $config = $this->tmp('[{"id":"server","repo":"nextcloud/server"},{"id":"activity","repo":"nextcloud/activity"}]'); + $tagOnly = $this->tmp('["nextcloud/activity","nextcloud/updater"]'); + + $this->assertSame( + ['nextcloud/activity', 'nextcloud/server', 'nextcloud/updater'], + ReleaseConfig::repos($config, $tagOnly), + ); + } + + public function testMajorFromStableConfig(): void + { + $this->assertSame(34, ReleaseConfig::majorFromConfigBasename('stable34', [])); + } + + public function testMajorFromMasterIsHighestPlusOne(): void + { + $tags = ['v32.0.5', 'v33.0.4', 'v34.0.0', 'v34.0.0rc1']; + $this->assertSame(35, ReleaseConfig::majorFromConfigBasename('master', $tags)); + } + + public function testLatestStablePicksNewest(): void + { + $tags = ['v33.0.0', 'v33.0.4', 'v33.0.10', 'v33.0.2', 'v34.0.0']; + $v = ReleaseConfig::latestStable(33, $tags); + $this->assertNotNull($v); + $this->assertSame([33, 0, 10], [$v->major, $v->minor, $v->patch]); + } + + public function testLatestStableIgnoresPrereleasesAndOtherMajors(): void + { + $tags = ['v33.0.4rc1', 'v34.0.0']; + $this->assertNull(ReleaseConfig::latestStable(33, $tags)); + } + + private function tmp(string $json): string + { + $path = tempnam(sys_get_temp_dir(), 'cfg'); + file_put_contents($path, $json); + return $path; + } +} diff --git a/tools/release/tests/RepoListTest.php b/tools/release/tests/RepoListTest.php index 28095ca5e28..767b8fe1d56 100644 --- a/tools/release/tests/RepoListTest.php +++ b/tools/release/tests/RepoListTest.php @@ -10,6 +10,13 @@ use Nextcloud\ReleaseTools\RepoList; use PHPUnit\Framework\TestCase; +/** + * What: merging the two config shapes (objects with .repo, plain strings) into + * a sorted unique repo list. + * + * Why: replaces the jq pipeline; the union/sort/dedupe must match so the same + * repos are processed. + */ final class RepoListTest extends TestCase { public function testMergesObjectAndStringFormatsSortedUnique(): void diff --git a/tools/release/tests/RepoTaggerTest.php b/tools/release/tests/RepoTaggerTest.php new file mode 100644 index 00000000000..c777dd6d42c --- /dev/null +++ b/tools/release/tests/RepoTaggerTest.php @@ -0,0 +1,94 @@ +seedBranch('nextcloud/activity', 'stable34', 'sha-stable', true); + return $api; + } + + public function testCreatesTagWhenMissing(): void + { + $api = $this->api(); + $r = (new RepoTagger($api))->tag('nextcloud/activity', 'stable34', 'v34.0.1', false); + $this->assertSame('OK', $r->status); + $this->assertSame(["tag\tnextcloud/activity\tv34.0.1\tsha-stable"], $api->journal); + } + + public function testSkipsWhenTagExistsWithoutForce(): void + { + $api = $this->api(); + $api->seedTag('nextcloud/activity', 'v34.0.1', 'old-sha'); + $r = (new RepoTagger($api))->tag('nextcloud/activity', 'stable34', 'v34.0.1', false); + $this->assertSame('SKIPPED', $r->status); + $this->assertSame([], $api->journal); + } + + public function testForceRecreatesTag(): void + { + $api = $this->api(); + $api->seedTag('nextcloud/activity', 'v34.0.1', 'old-sha'); + $r = (new RepoTagger($api))->tag('nextcloud/activity', 'stable34', 'v34.0.1', true); + $this->assertSame('OK', $r->status); + $this->assertSame(["retag\tnextcloud/activity\tv34.0.1\tsha-stable\tforce=true"], $api->journal); + } + + public function testServerRepoNeverForceRetagged(): void + { + $api = new FakeGitHubApi(); + $api->seedBranch('nextcloud/server', 'stable34', 'sha-x', true); + $api->seedTag('nextcloud/server', 'v34.0.1', 'old-sha'); + $r = (new RepoTagger($api))->tag('nextcloud/server', 'stable34', 'v34.0.1', true); + $this->assertSame('SKIPPED', $r->status, 'server tags are immutable even with --force'); + $this->assertSame([], $api->journal); + } + + public function testFallsBackToDefaultBranch(): void + { + $api = new FakeGitHubApi(); + // No stable34; default branch is main. + $api->seedBranch('nextcloud/notes', 'main', 'sha-main', true); + $r = (new RepoTagger($api))->tag('nextcloud/notes', 'stable34', 'v34.0.1', false); + $this->assertSame('OK', $r->status); + $this->assertSame('main', $r->branch); + $this->assertSame(["tag\tnextcloud/notes\tv34.0.1\tsha-main"], $api->journal); + } + + public function testNoBranchFails(): void + { + $api = new FakeGitHubApi(); // nothing seeded + $r = (new RepoTagger($api))->tag('nextcloud/ghost', 'stable34', 'v34.0.1', false); + $this->assertSame('FAILED', $r->status); + $this->assertSame([], $api->journal); + } + + public function testDryRunChangesNothing(): void + { + $api = $this->api(); + $r = (new RepoTagger($api, dryRun: true))->tag('nextcloud/activity', 'stable34', 'v34.0.1', false); + $this->assertSame('OK', $r->status); + $this->assertSame([], $api->journal); + $this->assertStringContainsString('would', $r->detail); + } +} diff --git a/tools/release/tests/TagPolicyTest.php b/tools/release/tests/TagPolicyTest.php new file mode 100644 index 00000000000..19494dfca23 --- /dev/null +++ b/tools/release/tests/TagPolicyTest.php @@ -0,0 +1,45 @@ +assertSame(TagPolicy::ACTION_CREATE, TagPolicy::decide('nextcloud/activity', false, false)); + $this->assertSame(TagPolicy::ACTION_CREATE, TagPolicy::decide('nextcloud/server', false, true)); + } + + public function testSkipWhenExistsWithoutForce(): void + { + $this->assertSame(TagPolicy::ACTION_SKIP, TagPolicy::decide('nextcloud/activity', true, false)); + } + + public function testRecreateOnForceForNormalRepos(): void + { + $this->assertSame(TagPolicy::ACTION_RECREATE, TagPolicy::decide('nextcloud/activity', true, true)); + } + + public function testServerReposNeverRecreate(): void + { + $this->assertSame(TagPolicy::ACTION_SKIP, TagPolicy::decide('nextcloud/server', true, true)); + $this->assertSame(TagPolicy::ACTION_SKIP, TagPolicy::decide('nextcloud-releases/server', true, true)); + $this->assertTrue(TagPolicy::isProtected('nextcloud/server')); + $this->assertFalse(TagPolicy::isProtected('nextcloud/activity')); + } +} diff --git a/tools/release/tests/VersionTest.php b/tools/release/tests/VersionTest.php index 7fedc3da8a2..722d1030a7c 100644 --- a/tools/release/tests/VersionTest.php +++ b/tools/release/tests/VersionTest.php @@ -11,6 +11,12 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +/** + * What: tag string -> Version parsing (major/minor/patch, prerelease, first-beta). + * + * Why: this is the root of every milestone decision; the bash used cut/sed/regex + * that were easy to get subtly wrong (suffix stripping, .0.0beta1 detection). + */ final class VersionTest extends TestCase { /** From 1926e33c3cf7acdc161dda3acc0ed0cdd3671a9c Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Tue, 9 Jun 2026 10:15:51 +0200 Subject: [PATCH 3/7] feat(release-tag): add dry_run dispatch input Signed-off-by: skjnldsv --- .github/workflows/release-tag.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index bba7651d444..a7324ed20e5 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -20,6 +20,11 @@ on: required: false type: boolean default: false + dry_run: + description: 'Preview tagging without creating tags' + required: false + type: boolean + default: false permissions: contents: read @@ -106,4 +111,7 @@ jobs: if [ "${{ inputs.force }}" = "true" ]; then ARGS+=("--force") fi + if [ "${{ inputs.dry_run }}" = "true" ]; then + ARGS+=("--dry-run") + fi php tools/release/bin/console repo:tag "${ARGS[@]}" From da60f371e31907cf6f161206249f266c93704fe7 Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Tue, 9 Jun 2026 10:17:56 +0200 Subject: [PATCH 4/7] fix(release-tools): pin composer platform to php 8.3 for lock parity with CI Signed-off-by: skjnldsv --- tools/release/composer.json | 5 +++- tools/release/composer.lock | 50 ++++++++++++++++++++----------------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/tools/release/composer.json b/tools/release/composer.json index 2023ff62637..90eadf441dd 100644 --- a/tools/release/composer.json +++ b/tools/release/composer.json @@ -26,6 +26,9 @@ "test": "phpunit" }, "config": { - "sort-packages": true + "sort-packages": true, + "platform": { + "php": "8.3.0" + } } } diff --git a/tools/release/composer.lock b/tools/release/composer.lock index 12128c6f627..970a32fb6c1 100644 --- a/tools/release/composer.lock +++ b/tools/release/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a62d123a30569f588ad68c02316f30cf", + "content-hash": "624b29ed568d69472476298e3ab50783", "packages": [ { "name": "clue/stream-filter", @@ -1403,20 +1403,20 @@ }, { "name": "symfony/options-resolver", - "version": "v8.1.0", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "88f9c561f678a02d54b897014049fa839e33ff82" + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82", - "reference": "88f9c561f678a02d54b897014049fa839e33ff82", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", "shasum": "" }, "require": { - "php": ">=8.4.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3" }, "type": "library", @@ -1450,7 +1450,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v8.1.0" + "source": "https://github.com/symfony/options-resolver/tree/v7.4.8" }, "funding": [ { @@ -1470,7 +1470,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/polyfill-ctype", @@ -1980,34 +1980,35 @@ }, { "name": "symfony/string", - "version": "v8.1.0", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", - "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/polyfill-ctype": "^1.8", - "symfony/polyfill-intl-grapheme": "^1.33", - "symfony/polyfill-intl-normalizer": "^1.0", - "symfony/polyfill-mbstring": "^1.0" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" }, "conflict": { "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.4|^8.0", - "symfony/http-client": "^7.4|^8.0", - "symfony/intl": "^7.4|^8.0", + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^7.4|^8.0" + "symfony/var-exporter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -2046,7 +2047,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.1.0" + "source": "https://github.com/symfony/string/tree/v7.4.13" }, "funding": [ { @@ -2066,7 +2067,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-05-23T15:23:29+00:00" } ], "packages-dev": [ @@ -3762,5 +3763,8 @@ "php": ">=8.3" }, "platform-dev": {}, + "platform-overrides": { + "php": "8.3.0" + }, "plugin-api-version": "2.9.0" } From 4dc86d3ec949e065d6295b6d61ea757c83959f7e Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Tue, 9 Jun 2026 10:21:41 +0200 Subject: [PATCH 5/7] test(release-tools): move FakeGitHubApi to tests, add PHP matrix + coverage Signed-off-by: skjnldsv --- .github/workflows/test-release-tools.yml | 21 ++++++++++++++++--- tools/release/.gitignore | 1 + tools/release/tests/MilestoneAuditorTest.php | 2 +- tools/release/tests/MilestoneUpdaterTest.php | 2 +- tools/release/tests/RepoTaggerTest.php | 2 +- .../Support}/FakeGitHubApi.php | 7 +++++-- 6 files changed, 27 insertions(+), 8 deletions(-) rename tools/release/{src/GitHub => tests/Support}/FakeGitHubApi.php (96%) diff --git a/.github/workflows/test-release-tools.yml b/.github/workflows/test-release-tools.yml index 3ecc31f35b9..6feeb6dbd1e 100644 --- a/.github/workflows/test-release-tools.yml +++ b/.github/workflows/test-release-tools.yml @@ -15,6 +15,11 @@ permissions: jobs: test: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-version: ['8.3', '8.4'] + name: test (php ${{ matrix.php-version }}) defaults: run: working-directory: tools/release @@ -26,11 +31,21 @@ jobs: - name: Set up PHP uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2.37.1 with: - php-version: '8.3' + php-version: ${{ matrix.php-version }} + coverage: pcov tools: composer - name: Install dependencies run: composer install --no-interaction --no-progress - - name: PHPUnit - run: composer test + - name: PHPUnit with coverage + run: vendor/bin/phpunit --coverage-text --coverage-clover coverage.xml + + - name: Upload coverage to Codecov + if: matrix.php-version == '8.3' + uses: codecov/codecov-action@v5 + with: + files: tools/release/coverage.xml + flags: release-tools + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false diff --git a/tools/release/.gitignore b/tools/release/.gitignore index 7f78132bebb..8c7b0b90d37 100644 --- a/tools/release/.gitignore +++ b/tools/release/.gitignore @@ -1,2 +1,3 @@ /vendor/ /.phpunit.result.cache +/coverage.xml diff --git a/tools/release/tests/MilestoneAuditorTest.php b/tools/release/tests/MilestoneAuditorTest.php index 6d144eac57d..7f167301d1b 100644 --- a/tools/release/tests/MilestoneAuditorTest.php +++ b/tools/release/tests/MilestoneAuditorTest.php @@ -7,7 +7,7 @@ namespace Nextcloud\ReleaseTools\Tests; -use Nextcloud\ReleaseTools\GitHub\FakeGitHubApi; +use Nextcloud\ReleaseTools\Tests\Support\FakeGitHubApi; use Nextcloud\ReleaseTools\MilestoneAuditor; use Nextcloud\ReleaseTools\Version; use PHPUnit\Framework\TestCase; diff --git a/tools/release/tests/MilestoneUpdaterTest.php b/tools/release/tests/MilestoneUpdaterTest.php index d373f4e1060..8225be61624 100644 --- a/tools/release/tests/MilestoneUpdaterTest.php +++ b/tools/release/tests/MilestoneUpdaterTest.php @@ -7,7 +7,7 @@ namespace Nextcloud\ReleaseTools\Tests; -use Nextcloud\ReleaseTools\GitHub\FakeGitHubApi; +use Nextcloud\ReleaseTools\Tests\Support\FakeGitHubApi; use Nextcloud\ReleaseTools\MilestoneUpdater; use Nextcloud\ReleaseTools\Version; use PHPUnit\Framework\TestCase; diff --git a/tools/release/tests/RepoTaggerTest.php b/tools/release/tests/RepoTaggerTest.php index c777dd6d42c..46a1aff4c70 100644 --- a/tools/release/tests/RepoTaggerTest.php +++ b/tools/release/tests/RepoTaggerTest.php @@ -7,7 +7,7 @@ namespace Nextcloud\ReleaseTools\Tests; -use Nextcloud\ReleaseTools\GitHub\FakeGitHubApi; +use Nextcloud\ReleaseTools\Tests\Support\FakeGitHubApi; use Nextcloud\ReleaseTools\RepoTagger; use PHPUnit\Framework\TestCase; diff --git a/tools/release/src/GitHub/FakeGitHubApi.php b/tools/release/tests/Support/FakeGitHubApi.php similarity index 96% rename from tools/release/src/GitHub/FakeGitHubApi.php rename to tools/release/tests/Support/FakeGitHubApi.php index 96b63fcb960..75ba570b0de 100644 --- a/tools/release/src/GitHub/FakeGitHubApi.php +++ b/tools/release/tests/Support/FakeGitHubApi.php @@ -5,7 +5,10 @@ declare(strict_types=1); -namespace Nextcloud\ReleaseTools\GitHub; +namespace Nextcloud\ReleaseTools\Tests\Support; + +use Nextcloud\ReleaseTools\GitHub\GitHubApi; +use Nextcloud\ReleaseTools\GitHub\Milestone; /** * In-memory GitHubApi for tests. Seeded with milestones/issues/tags, it records @@ -41,7 +44,7 @@ public function seedIssue(string $repo, int $number, int $milestoneNumber): void $this->issues[$repo][$number] = $milestoneNumber; } - public function seedTag(string $repo, string $tag, string $sha = 'sha-' . '0000'): void + public function seedTag(string $repo, string $tag, string $sha = 'sha-0000'): void { $this->tags[$repo][$tag] = $sha; } From f59d0a22c2a35a37dfb382d2fd2935ca71072e54 Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Tue, 9 Jun 2026 10:23:12 +0200 Subject: [PATCH 6/7] test(release-tools): split CI into per-area test suites (matrix) Signed-off-by: skjnldsv --- .github/workflows/test-release-tools.yml | 13 ++++++------- tools/release/phpunit.xml.dist | 23 +++++++++++++++++++++-- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test-release-tools.yml b/.github/workflows/test-release-tools.yml index 6feeb6dbd1e..b5b3dd8ae1e 100644 --- a/.github/workflows/test-release-tools.yml +++ b/.github/workflows/test-release-tools.yml @@ -18,8 +18,8 @@ jobs: strategy: fail-fast: false matrix: - php-version: ['8.3', '8.4'] - name: test (php ${{ matrix.php-version }}) + suite: [domain, milestones-update, milestones-audit, tagger] + name: test (${{ matrix.suite }}) defaults: run: working-directory: tools/release @@ -31,21 +31,20 @@ jobs: - name: Set up PHP uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2.37.1 with: - php-version: ${{ matrix.php-version }} + php-version: '8.3' coverage: pcov tools: composer - name: Install dependencies run: composer install --no-interaction --no-progress - - name: PHPUnit with coverage - run: vendor/bin/phpunit --coverage-text --coverage-clover coverage.xml + - name: PHPUnit (${{ matrix.suite }}) + run: vendor/bin/phpunit --testsuite ${{ matrix.suite }} --coverage-text --coverage-clover coverage.xml - name: Upload coverage to Codecov - if: matrix.php-version == '8.3' uses: codecov/codecov-action@v5 with: files: tools/release/coverage.xml - flags: release-tools + flags: ${{ matrix.suite }} token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: false diff --git a/tools/release/phpunit.xml.dist b/tools/release/phpunit.xml.dist index fb3ad6dcc65..33046978295 100644 --- a/tools/release/phpunit.xml.dist +++ b/tools/release/phpunit.xml.dist @@ -6,8 +6,27 @@ failOnWarning="true" failOnRisky="true"> - - tests + + + tests/VersionTest.php + tests/MilestonePlanTest.php + tests/DueDateTest.php + tests/RepoListTest.php + tests/AuditExpectationTest.php + tests/TagPolicyTest.php + tests/ReleaseConfigTest.php + + + + tests/MilestoneUpdaterTest.php + + + + tests/MilestoneAuditorTest.php + + + + tests/RepoTaggerTest.php From 32f5c4614c7252fbaff360237fe5e4ac17bb99ca Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Tue, 9 Jun 2026 10:26:12 +0200 Subject: [PATCH 7/7] docs: friendlier release-tools README + release process in root README Signed-off-by: skjnldsv --- README.md | 45 ++++++++--- tools/release/README.md | 165 ++++++++++++++++++++++------------------ 2 files changed, 128 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 93a720d7866..f397183c07c 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,41 @@ Release artifacts and automation for Nextcloud server. Branches are synced daily ## How releases work -When a release is published on this repository, three things happen in parallel: - -1. **Changelog** is generated and attached to the release -2. **All app repositories** get tagged at their stable branch HEAD -3. **Release archives** are built independently and compared against the release script output -4. **Milestones** are updated across all repos (stable releases and first betas only) - -The tagger and builder can also be run manually for re-tagging or testing. +A release is driven entirely by its tag (for example `v34.0.4`). Everything else +- which branch, which repositories, which milestones - is derived from it, so +there is no per-release bookkeeping to remember. + +The pipeline (`release.yml`) runs these reusable workflows: + +1. **Tag** (`release-tag.yml`) - tag every release repository at the tip of its + release branch. +2. **Changelog** (`release-changelog.yml`) - generate the changelog and attach + it to the GitHub release. +3. **Build** (`release-build.yml`) - build the archives independently and compare + them against the release script's output. +4. **Milestones** (`release-milestones.yml`) - tidy milestones across all repos + (stable releases and first betas only). +5. **Updater** (`release-updater.yml`) - open a PR to the updater server with the + new release config. + +Tagging and milestone management are unit-tested PHP commands in +[`tools/release/`](tools/release/README.md); the build/package/sign steps are +bash in [`.github/scripts/`](.github/scripts/README.md). + +### Branch and config selection + +- A `.0.0` **alpha/beta of a new major** comes from `master` and uses + `master.json`. +- **Everything else** (stable releases and RCs) comes from `stableN` and uses + `stableN.json`. + +### Milestone rules in short + +Two open patch milestones are always kept. A stable `vX.Y.Z` closes its own +milestone, moves open issues to `X.Y.(Z+1)`, and creates `X.Y.(Z+2)`. The first +beta of a major opens the *next* major milestone (`vN.0.0beta1` creates +`Nextcloud N+1`). Full details and examples are in +[`tools/release/README.md`](tools/release/README.md). ## Release configuration @@ -26,7 +53,7 @@ When a new app is added to the release or an existing one is removed, edit the c ## Running manually -**Re-tag a release**: Actions > "Tag all repositories" > enter tag (e.g., `v34.0.1`). Check "force" to overwrite existing tags. +**Re-tag a release**: Actions > "Tag all repositories" > enter tag (e.g., `v34.0.1`). Check "force" to overwrite existing tags (server repos are never re-tagged), or "dry run" to preview. **Rebuild a release**: Actions > "Build and compare release" > enter tag. Compares the result against the release script's archives on the same GitHub release. diff --git a/tools/release/README.md b/tools/release/README.md index 704f4a89d22..9b3c780b22c 100644 --- a/tools/release/README.md +++ b/tools/release/README.md @@ -2,107 +2,126 @@ - SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - SPDX-License-Identifier: MIT --> -# Release tools (PHP) +# Release tools -Unit-tested PHP for the GitHub-API parts of the release automation, migrated -from the bash scripts in `.github/scripts/`. The logic-heavy, API-driven steps -(milestones, tagging) live here where they get real types, return values and -PHPUnit coverage. The filesystem/packaging scripts stay in bash. +The release automation that talks to the GitHub API lives here, as small PHP +commands with real unit tests. It used to be bash in `.github/scripts/`; the +parts that parse versions, walk the API and make decisions moved here where they +are far easier to read and to test. The parts that shuffle files around (build, +package, sign) are still bash, because that is what bash is good at. -## Commands +There are three commands today: + +- **`milestones:update`** - after a release, tidy up the milestones. +- **`milestones:audit`** - check the milestones look right (read-only). +- **`repo:tag`** - tag all the release repositories. ```bash -cd tools/release && composer install -GH_TOKEN=... php bin/console +cd tools/release +composer install +GH_TOKEN= php bin/console milestones:update --help ``` -| command | replaces | what | -|---|---|---| -| `milestones:update [--dry-run] [--next-due Y-M-D] [--upcoming-due Y-M-D]` | `update-milestones.sh` | close/create milestones, move issues | -| `milestones:audit ` | `audit-milestones.sh` | report milestone inconsistencies (read-only) | -| `repo:tag [--force] [--dry-run]` | `tag-repo.sh` | tag all release repos at a branch | +The token is the same `RELEASE_TOKEN` the workflows already use. In CI you never +run these by hand; the release workflows do. -`GH_TOKEN` (or `GITHUB_TOKEN`) must be a token with write access to the org -repos - the same `RELEASE_TOKEN` the workflows already pass. +## How a release drives the tooling -## Release auto-logic +You give it a tag (like `v34.0.4`) and it works everything else out. The rules +below are deliberately mechanical so nobody has to remember the bookkeeping. -This is the behaviour the workflows encode. It is intentionally rule-based so a -human rarely has to think about milestone/tag bookkeeping. +### Which repos and which branch -### Config & branch selection (from the tag) +From the tag, the workflow picks: -- **Alpha/beta of a new major** (`vN.0.0alpha*` / `vN.0.0beta*`) -> branch - `master`, config `master.json`. -- **Everything else** (stable `vN.M.P`, and RCs) -> branch `stableN`, config +- an **alpha/beta of a brand-new major** (`v35.0.0beta3`) -> the `master` branch + and `master.json`; +- **anything else** (stable releases and RCs) -> the `stableN` branch and `stableN.json`. -- The repo list is the **union** of the config file (objects with `.repo`) and - `tag-only.json` (plain strings), sorted and de-duplicated. + +The list of repositories to act on is the config file plus `tag-only.json` +(repos that are tagged but not built), merged and de-duplicated. ### Milestones (`milestones:update`) -The invariant: **two patch milestones stay open** at all times. - -- **Stable release `vX.Y.Z`** (not a pre-release): - 1. find the released milestone - `Nextcloud X.Y.Z`, or the short `Nextcloud X` - for an initial `X.0.0`; - 2. ensure `Nextcloud X.Y.(Z+1)` exists (create if missing) - the **next** patch; - 3. **move all open issues** from the released milestone to the next one - (gathered up front, so none are skipped; pull requests are left alone); - 4. **close** the released milestone; - 5. ensure `Nextcloud X.Y.(Z+2)` exists (create if missing) - the **upcoming** patch. - - `--next-due` / `--upcoming-due` set the due date on the next / upcoming - milestone respectively. The date is applied whether the milestone is created - now **or already exists**, so re-running fixes stale/missing dates and is - idempotent. -- **First beta `vN.0.0beta1`**: only **create `Nextcloud N+1`** (the next major) - where missing. `Nextcloud N` already exists from the previous cycle. No close, - no move, no due date. -- **Any other pre-release** (alpha/rc, later betas): **no-op**. -- `--dry-run` reads state and logs "Would ..." but changes nothing. +The golden rule: **there are always two open patch milestones**, so people +always have somewhere to file the next two patch releases. + +**A stable release**, say `v33.0.4`: + +1. find the `Nextcloud 33.0.4` milestone (for a `.0.0` it may be the short + `Nextcloud 34` form instead); +2. make sure `Nextcloud 33.0.5` exists - that is where issues go next; +3. move every open issue from 33.0.4 to 33.0.5 (pull requests are left alone); +4. close 33.0.4; +5. make sure `Nextcloud 33.0.6` exists, so two patch milestones stay open. + +You can hand it due dates for those two milestones: + +```bash +php bin/console milestones:update v33.0.4 stable33.json tag-only.json \ + --next-due 2026-07-02 --upcoming-due 2026-08-27 +``` + +The date is applied whether the milestone is brand new or already there, so +re-running with the same dates is harmless and also fixes any wrong dates. + +**The first beta of a new major** (`v34.0.0beta1`) is special: it only creates +the *next* major milestone, `Nextcloud 35`. `Nextcloud 34` already exists from +the previous cycle, so there is nothing else to do. + +**Any other pre-release** (alphas, RCs, later betas) does nothing. + +Add `--dry-run` to see exactly what it would do without touching anything. ### Audit (`milestones:audit`) -Read-only. Derives the major from the config name (`stableN` -> N; `master` -> -highest released major + 1), finds the latest stable release of that major, and -flags: released milestone still open, missing/closed next or upcoming, an -upcoming milestone without a due date, and orphaned patch milestones (open but at -or below the released patch). Exits non-zero if anything is flagged. If no stable -release exists yet for the major, it skips. +A read-only health check. It figures out the major from the config name, looks +up the latest stable release, and complains if: + +- the released milestone is still open, +- the next or upcoming milestone is missing or closed, +- the upcoming milestone has no due date, +- an old patch milestone is still open (an "orphan"). + +It changes nothing and exits non-zero if it finds problems. If the major has no +stable release yet, it simply skips. ### Tags (`repo:tag`) -- Per repo, resolve the tag target: prefer the release `branch` (stableN/master), - fall back to the repo's **default branch**; fail that repo if neither exists. -- Create the tag as a **lightweight ref** via the git-refs API (same result as - the old `gh release create`, no clone/push). -- If the tag already exists: **skip**, unless `--force` (then recreate) - but the - **server repos are immutable**: `nextcloud/server` and - `nextcloud-releases/server` are never re-tagged even with `--force`. -- `--dry-run` reports what it would do without writing. +For each repository it tags the tip of the release branch (falling back to the +repo's default branch if that branch does not exist). The tag is a plain +lightweight tag created through the API - no cloning, no pushing. -## Architecture & testing +If a tag already exists it is left alone, unless you pass `--force`. The one +exception: **the server repositories are never re-tagged**, even with `--force`, +because a published release tag must never move. -Three layers, so the logic is testable without touching GitHub: +## How it is built -- **Domain** (`src/Version.php`, `MilestonePlan`, `DueDate`, `RepoList`, - `AuditExpectation`, `TagPolicy`) - pure, no I/O. -- **Service** (`MilestoneUpdater`, `MilestoneAuditor`, `RepoTagger`) - the - orchestration, talking to a `GitHub\GitHubApi` interface. -- **Command** (`src/Command/`) - thin Symfony Console glue; builds the real - `GitHub\KnpGitHubApi` (knplabs/github-api) from `GH_TOKEN`. +Three thin layers, so the interesting logic never needs the network to be +tested: -Tests run the services against `GitHub\FakeGitHubApi` - an in-memory GitHub that -records every mutation to a **journal**, so a test asserts the exact sequence of -create/close/move/setdue/tag calls (the PHP equivalent of the old -`fake-gh.sh` + journal). Only the thin `KnpGitHubApi` adapter touches the -network; it's verified by dry-running the workflows. Each test file documents -what it covers and why in its class docblock. +- **domain** - pure functions and value objects (version parsing, milestone + names, the due-date format, the tag rules); +- **services** - the actual steps, talking to a small `GitHubApi` interface; +- **commands** - Symfony Console wrappers that wire it together and build the + real GitHub client (`knplabs/github-api`) from `GH_TOKEN`. -## Develop +Tests run the services against an in-memory fake GitHub that records every change +it is asked to make, so a test can check the exact sequence of +create/close/move/tag calls. Only the thin real client touches the network, and +that path is checked by dry-running the workflows. Each test file says, at the +top, what it covers and why. + +## Working on it ```bash cd tools/release composer install -composer test +composer test # all suites +vendor/bin/phpunit --testsuite tagger # one area ``` + +CI runs the suites split by area (domain, milestones-update, milestones-audit, +tagger) and reports coverage.