diff --git a/.github/workflows/joomla-versions.yml b/.github/workflows/joomla-versions.yml new file mode 100644 index 0000000..15eba35 --- /dev/null +++ b/.github/workflows/joomla-versions.yml @@ -0,0 +1,118 @@ +name: Joomla versions + +# The integration tests of ci.yml run on one Joomla! version per major version +# (the matrix), each with a SHA-256 in tests/ci/joomla-checksums.sha256. Dependabot +# can't follow those, so this weekly job looks at the releases of joomla/joomla-cms: +# +# - a newer patch release of a version in the matrix: a pull request that raises +# the matrix and adds the checksum (branch joomla-versions, rewritten by this +# job while the pull request is open) +# - a release of a newer minor/major line than the matrix has: an issue, because +# whether BFStop supports it is a decision, not a version bump +# +# See tests/README.md, "Joomla versions of the CI matrix". + +on: + schedule: + - cron: '41 5 * * 2' + workflow_dispatch: + +permissions: + contents: write # the branch joomla-versions + pull-requests: write + issues: write + actions: write # to start ci.yml on that branch (see below) + +concurrency: joomla-versions + +jobs: + check: + name: Newer Joomla! releases + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.4' + coverage: none + - name: Look at the releases of joomla/joomla-cms + run: | + set -euo pipefail + gh api "repos/joomla/joomla-cms/releases?per_page=100" > "$RUNNER_TEMP/releases.json" + php tests/ci/joomla-updates.php plan "$RUNNER_TEMP/releases.json" | tee "$RUNNER_TEMP/plan.json" + - name: Issue for a newer minor/major line + run: | + set -euo pipefail + while read -r row; do + line="$(jq -r .line <<< "$row")" + version="$(jq -r .version <<< "$row")" + title="Joomla $line is available: add it to the CI matrix?" + known="$(gh issue list --state all --search "in:title Joomla $line" --json number,title \ + | jq -r --arg t "$title" 'map(select(.title == $t)) | .[0].number // empty')" + if [ -z "$known" ]; then + gh issue create --title "$title" --label dependencies --body "Joomla! $version (line $line) is released and newer than the versions the CI matrix tests. To support it: raise or extend the matrix in \`.github/workflows/ci.yml\` (and in com_bfstop), add the package's SHA-256 to \`tests/ci/joomla-checksums.sha256\` (the file says how to check it), and see what the tests say. Opened by the weekly job $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID; it doesn't ask again for this line." + fi + done < <(jq -c '.new_lines[]' "$RUNNER_TEMP/plan.json") + - name: Pull request for newer patch releases + run: | + set -euo pipefail + if [ "$(jq '.bumps | length' "$RUNNER_TEMP/plan.json")" -eq 0 ]; then + echo "The matrix has the newest patch release of each of its versions." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B joomla-versions + body="$RUNNER_TEMP/body.md" + versions="" + { + echo "The matrix of the integration tests runs older patch releases than are available. This raises it and adds the SHA-256 of each package to \`tests/ci/joomla-checksums.sha256\`." + echo + } > "$body" + while read -r row; do + from="$(jq -r .from <<< "$row")" + to="$(jq -r .to <<< "$row")" + digest="$(jq -r .digest <<< "$row")" + package="Joomla_${to}-Stable-Full_Package.tar.gz" + curl -fsSL -o "$RUNNER_TEMP/$package" "https://github.com/joomla/joomla-cms/releases/download/$to/$package" + actual="$(sha256sum "$RUNNER_TEMP/$package" | cut -d' ' -f1)" + if [ -n "$digest" ] && [ "$digest" != "$actual" ]; then + echo "::error::The download of $package has SHA-256 $actual, but the GitHub API lists $digest for the asset. Not touching the matrix." + exit 1 + fi + php tests/ci/joomla-updates.php apply "$from" "$to" "$actual" + versions="${versions:+$versions, }$to" + if [ -n "$digest" ]; then + echo "- **$from -> $to**: SHA-256 \`$actual\`, the same as the digest GitHub lists for the release asset." >> "$body" + else + echo "- **$from -> $to**: SHA-256 \`$actual\` of the downloaded package (GitHub listed no digest for the asset)." >> "$body" + fi + done < <(jq -c '.bumps[]' "$RUNNER_TEMP/plan.json") + { + echo + echo "**Before merging, please compare the MD5/SHA1 of each package with the ones on https://downloads.joomla.org** (the page of the version lists them): the SHA-256 comes from the GitHub release, not from Joomla's own page." + echo + echo "- The entries of the previous versions stay in the checksum file: the com_bfstop workflow installs through the same file. Remove them once both repositories have moved on." + echo "- com_bfstop has its own matrix; raise it there if you want the same versions." + echo "- This branch (\`joomla-versions\`) is rewritten by the weekly job while the pull request is open. A push made with the workflow's token doesn't start the \`pull_request\` workflows, so the job starts \`ci.yml\` on the branch itself; its runs are listed on the branch's latest commit." + echo + echo "Opened by $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + } >> "$body" + git commit -a -q -m "CI: Joomla $versions" + # nothing new since the last run (the matrix and checksum file are what differs)? + if git fetch -q origin joomla-versions 2> /dev/null \ + && git diff --quiet FETCH_HEAD HEAD -- .github/workflows/ci.yml tests/ci/joomla-checksums.sha256; then + echo "The branch joomla-versions has these versions already." + exit 0 + fi + git push --force origin joomla-versions + number="$(gh pr list --head joomla-versions --state open --json number --jq '.[0].number // empty')" + if [ -z "$number" ]; then + gh pr create --base main --head joomla-versions --title "CI: Joomla $versions" --body-file "$body" + gh pr edit joomla-versions --add-label dependencies || true + else + gh pr edit "$number" --title "CI: Joomla $versions" --body-file "$body" + fi + gh workflow run ci.yml --ref joomla-versions diff --git a/tests/README.md b/tests/README.md index 35a339b..2ed19aa 100644 --- a/tests/README.md +++ b/tests/README.md @@ -29,6 +29,30 @@ the site only needs to be set up again when the database schema changes. GitHub Actions runs everything on each push and pull request, on Joomla 5 and 6 with MySQL, MariaDB and PostgreSQL, see `.github/workflows/ci.yml`. +### Joomla versions of the CI matrix + +The matrix runs one Joomla version per major version, each with its SHA-256 in +`tests/ci/joomla-checksums.sha256`. Dependabot can't follow those, so +`.github/workflows/joomla-versions.yml` looks at the releases of +`joomla/joomla-cms` every week (and on demand: Actions, "Joomla versions", Run +workflow): + +- a newer **patch release** of a version in the matrix gets a pull request + (branch `joomla-versions`) that raises the matrix and adds the checksum. The + hash is the one of the downloaded package, which must equal the digest GitHub + lists for the release asset. Compare MD5/SHA1 with downloads.joomla.org + before merging; that is the check against Joomla's own page. A push with the + workflow's token doesn't start `pull_request` workflows, so the job starts + `ci.yml` on the branch itself. +- a release of a **newer minor/major line** (e.g. 6.2 when the matrix has 6.1) + only gets an issue: whether to support it is a decision. + +For the pull requests, the repository setting "Allow GitHub Actions to create +and approve pull requests" has to be on. The planning and the edits are +`tests/ci/joomla-updates.php` (unit-tested in `tests/Unit/JoomlaUpdatesTest.php`). +Entries of old versions stay in the checksum file as long as com_bfstop's +matrix, which installs through the same file, may still use them. + ## Running locally Unit tests only: diff --git a/tests/Unit/JoomlaUpdatesTest.php b/tests/Unit/JoomlaUpdatesTest.php new file mode 100644 index 0000000..8c1ca9c --- /dev/null +++ b/tests/Unit/JoomlaUpdatesTest.php @@ -0,0 +1,153 @@ + "Joomla_$tag-Stable-Full_Package.tar.gz"); + if ($digest !== null) + { + $asset['digest'] = $digest; + } + return array( + 'tag_name' => $tag, + 'draft' => $options['draft'] ?? false, + 'prerelease' => $options['prerelease'] ?? false, + 'assets' => ($options['assets'] ?? true) ? array(array('name' => 'other.zip'), $asset) : array(), + ); + } + + public function testMatrixVersions() + { + $this->assertSame(array('5.4.8', '6.1.3'), matrixVersions(self::Workflow)); + } + + public function testStableReleasesIgnoreDraftsPrereleasesOddTagsAndMissingPackages() + { + $stable = stableReleases(array( + self::release('6.1.4'), + self::release('6.2.0', array('prerelease' => true)), + self::release('6.2.0-beta1'), + self::release('6.3.0', array('draft' => true)), + self::release('5.4.9', array('assets' => false)), + self::release('3.10.12-elts'), + self::release('5.4.10', array('digest' => null)), + array('tag_name' => '5.4.11', 'assets' => array(array('name' => 'Joomla_5.4.12-Stable-Full_Package.tar.gz'))), + )); + $this->assertSame(array('6.1.4' => str_repeat('b', 64), '5.4.10' => ''), $stable); + } + + public function testPatchReleaseIsABumpOfItsLineOnly() + { + $stable = array('5.4.8' => '', '5.4.9' => 'x', '5.4.10' => 'y', '6.1.3' => '', '6.0.9' => 'z'); + $plan = plan(array('5.4.8', '6.1.3'), $stable); + $this->assertSame(array(array('from' => '5.4.8', 'to' => '5.4.10', 'digest' => 'y')), $plan['bumps']); + $this->assertSame(array(), $plan['new_lines'], 'older lines (6.0) are not news'); + } + + public function testNewerLinesAreOnlyReportedOncePerLine() + { + $stable = array('5.4.8' => '', '6.1.3' => '', '6.1.4' => 'a', '6.2.0' => 'b', '6.2.1' => 'c', '7.0.0' => 'd'); + $plan = plan(array('5.4.8', '6.1.3'), $stable); + $this->assertSame(array(array('from' => '6.1.3', 'to' => '6.1.4', 'digest' => 'a')), $plan['bumps']); + $this->assertSame(array( + array('line' => '6.2', 'version' => '6.2.1'), + array('line' => '7.0', 'version' => '7.0.0'), + ), $plan['new_lines']); + } + + public function testNothingToDoWhenCurrent() + { + $plan = plan(array('5.4.8', '6.1.3'), array('5.4.8' => '', '6.1.3' => '', '5.3.0' => '')); + $this->assertSame(array(), $plan['bumps']); + $this->assertSame(array(), $plan['new_lines']); + } + + public function testApplyReplacesTheVersionAndAddsTheChecksum() + { + list($workflow, $checksums) = apply(self::Workflow, self::Checksums, '5.4.8', '5.4.9', self::Hash9); + $this->assertSame(0, substr_count($workflow, '5.4.8')); + $this->assertSame(2, substr_count($workflow, 'joomla: 5.4.9,')); + $this->assertSame(1, substr_count($workflow, 'joomla: 6.1.3,')); + + $lines = explode("\n", rtrim($checksums)); + $this->assertContains(self::Hash8.' Joomla_5.4.8-Stable-Full_Package.tar.gz', $lines, 'the old entry stays'); + $this->assertContains(self::Hash9.' Joomla_5.4.9-Stable-Full_Package.tar.gz', $lines); + $this->assertLessThan( + array_search(self::Hash13.' Joomla_6.1.3-Stable-Full_Package.tar.gz', $lines), + array_search(self::Hash9.' Joomla_5.4.9-Stable-Full_Package.tar.gz', $lines), + 'the new entry is behind that of the old version'); + $this->assertStringContainsString("# 5.4.9 taken from the GitHub release asset", $checksums); + $this->assertStringContainsString('downloads.joomla.org/cms/joomla5/5-4-9', $checksums); + // the notes stay in the comment block, in front of the checksums + $this->assertLessThan(strpos($checksums, self::Hash8), strpos($checksums, '# 5.4.9')); + } + + public function testApplyKeepsWhatLintAndInstallerRead() + { + list($workflow, $checksums) = apply(self::Workflow, self::Checksums, '6.1.3', '6.1.4', self::Hash9); + $this->assertSame(array('5.4.8', '6.1.4'), matrixVersions($workflow)); + foreach (explode("\n", rtrim($checksums)) as $line) + { + $this->assertTrue($line === '' || $line[0] === '#' + || preg_match('/^[0-9a-f]{64} Joomla_\d+\.\d+\.\d+-Stable-Full_Package\.tar\.gz$/', $line) === 1, $line); + } + } + + #[DataProvider('badApplications')] + public function testApplyRefusesWhatItCannotDoSafely($from, $to, $hash, $checksums = self::Checksums) + { + $this->expectException(InvalidArgumentException::class); + apply(self::Workflow, $checksums, $from, $to, $hash); + } + + public static function badApplications() + { + return array( + 'other line' => array('5.4.8', '5.5.0', self::Hash9), + 'older' => array('6.1.3', '6.1.2', self::Hash9), + 'same' => array('6.1.3', '6.1.3', self::Hash9), + 'not in the matrix' => array('5.4.7', '5.4.9', self::Hash9), + 'garbage version' => array('5.4.8', '5.4.9; rm -rf /', self::Hash9), + 'short hash' => array('5.4.8', '5.4.9', 'abc'), + 'upper case hash' => array('5.4.8', '5.4.9', strtoupper(self::Hash9)), + 'already listed' => array('5.4.8', '5.4.9', self::Hash9, + self::Checksums.self::Hash9." Joomla_5.4.9-Stable-Full_Package.tar.gz\n"), + ); + } +} diff --git a/tests/ci/joomla-updates.php b/tests/ci/joomla-updates.php new file mode 100644 index 0000000..411ae45 --- /dev/null +++ b/tests/ci/joomla-updates.php @@ -0,0 +1,190 @@ + asset digest ('' if the API gives none) of the stable releases with a full package */ +function stableReleases(array $releases) +{ + $stable = array(); + foreach ($releases as $release) + { + $tag = $release['tag_name'] ?? ''; + if (!empty($release['draft']) || !empty($release['prerelease']) || !preg_match('/^\d+\.\d+\.\d+$/', $tag)) + { + continue; + } + foreach ($release['assets'] ?? array() as $asset) + { + if (($asset['name'] ?? '') === sprintf(JoomlaUpdatesPackage, $tag)) + { + $digest = $asset['digest'] ?? ''; + $stable[$tag] = preg_match('/^sha256:([0-9a-f]{64})$/', $digest, $m) ? $m[1] : ''; + } + } + } + return $stable; +} + +function line($version) +{ + return implode('.', array_slice(explode('.', $version), 0, 2)); +} + +function plan(array $matrix, array $stable) +{ + $versions = array_keys($stable); + usort($versions, 'version_compare'); + $bumps = array(); + $lines = array(); + foreach ($matrix as $current) + { + $newest = $current; + foreach ($versions as $candidate) + { + if (line($candidate) === line($current) && version_compare($candidate, $newest, '>')) + { + $newest = $candidate; + } + } + if ($newest !== $current) + { + $bumps[] = array('from' => $current, 'to' => $newest, 'digest' => $stable[$newest]); + } + } + // lines newer than any in the matrix; for each one its newest patch release + $newest = end($matrix); + foreach ($versions as $candidate) + { + $known = false; + foreach ($matrix as $current) + { + $known = $known || line($current) === line($candidate); + } + if (!$known && version_compare($candidate, $newest, '>')) + { + $lines[line($candidate)] = $candidate; + } + } + $newLines = array(); + foreach ($lines as $line => $version) + { + $newLines[] = array('line' => $line, 'version' => $version); + } + return array('matrix' => $matrix, 'bumps' => $bumps, 'new_lines' => $newLines); +} + +/** @return array(string, string) workflow and checksum file with $to instead of $from */ +function apply($workflow, $checksums, $from, $to, $sha256) +{ + foreach (array($from, $to) as $version) + { + if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) + { + throw new InvalidArgumentException("Not a version: $version"); + } + } + if (!preg_match('/^[0-9a-f]{64}$/', $sha256)) + { + throw new InvalidArgumentException('Not a SHA-256'); + } + if (line($from) !== line($to) || version_compare($to, $from, '<=')) + { + throw new InvalidArgumentException("$to is not a newer release of the $from line"); + } + $workflow = preg_replace('/\bjoomla:(\s*)'.preg_quote($from, '/').'\b/', 'joomla:${1}'.$to, $workflow, -1, $count); + if ($count === 0) + { + throw new InvalidArgumentException("Joomla $from is not in the matrix"); + } + $package = sprintf(JoomlaUpdatesPackage, $to); + if (strpos($checksums, $package) !== false) + { + throw new InvalidArgumentException("$package already has a checksum"); + } + $old = sprintf(JoomlaUpdatesPackage, $from); + $entry = $sha256.' '.$package; + $checksums = preg_replace('/^[0-9a-f]{64} '.preg_quote($old, '/').'$/m', '$0'."\n".$entry, $checksums, 1, $count); + if ($count !== 1) + { + throw new InvalidArgumentException("$old has no checksum entry to put the new one behind"); + } + // how it was checked, behind the notes of the other versions + $major = explode('.', $to)[0]; + $note = "# $to taken from the GitHub release asset by the joomla-versions workflow;\n" + ."# compare with downloads.joomla.org/cms/joomla$major/".str_replace('.', '-', $to); + $checksums = preg_replace('/^(?!#)/m', $note."\n", $checksums, 1); + return array($workflow, $checksums); +} + +function main(array $argv) +{ + $root = dirname(__DIR__, 2); + $workflowFile = $root.'/.github/workflows/ci.yml'; + $checksumFile = $root.'/tests/ci/joomla-checksums.sha256'; + $command = $argv[1] ?? ''; + if ($command === 'plan' && count($argv) === 3) + { + $releases = json_decode(file_get_contents($argv[2]), true); + if (!is_array($releases)) + { + fwrite(STDERR, "$argv[2] is not a list of releases\n"); + return 2; + } + echo json_encode(plan(matrixVersions(file_get_contents($workflowFile)), stableReleases($releases)), JSON_PRETTY_PRINT)."\n"; + return 0; + } + if ($command === 'apply' && count($argv) === 5) + { + try + { + list($workflow, $checksums) = apply(file_get_contents($workflowFile), file_get_contents($checksumFile), + $argv[2], $argv[3], $argv[4]); + } + catch (InvalidArgumentException $e) + { + fwrite(STDERR, $e->getMessage()."\n"); + return 1; + } + file_put_contents($workflowFile, $workflow); + file_put_contents($checksumFile, $checksums); + return 0; + } + fwrite(STDERR, "Usage: joomla-updates.php plan RELEASES.json | apply FROM TO SHA256\n"); + return 2; +} + +if (PHP_SAPI === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) +{ + exit(main($argv)); +}