diff --git a/.github/workflows/vortex-release.yml b/.github/workflows/vortex-release.yml index a799de9cc..0db1a74ce 100644 --- a/.github/workflows/vortex-release.yml +++ b/.github/workflows/vortex-release.yml @@ -30,6 +30,13 @@ jobs: outputs: release-version: ${{ steps.version.outputs.value }} + # The current major (built from this ref) and the other major (built from + # its '{N}.x' branch) are both derived from 'VORTEX_CURRENT_MAJOR' (default + # 1), so a single variable bump promotes a new major with no workflow edits. + env: + CURRENT_MAJOR: ${{ vars.VORTEX_CURRENT_MAJOR || '1' }} + OTHER_MAJOR: ${{ (vars.VORTEX_CURRENT_MAJOR || '1') == '1' && '2' || '1' }} + steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -85,13 +92,39 @@ jobs: ./build/installer.phar --no-interaction --no-cleanup --destination=test || exit 1 working-directory: .vortex/installer - - name: Upload artifact + - name: Upload v${{ env.CURRENT_MAJOR }} installer artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: vortex-installer + name: vortex-installer-v${{ env.CURRENT_MAJOR }} path: .vortex/installer/build/installer.phar if-no-files-found: error + # Build the other major's installer from its '{N}.x' branch so it is + # published alongside the current one. Checked out into a separate path so + # the current installer build above is untouched. Mirrors how the docs job + # pulls the other major's branch content. + - name: Checkout v${{ env.OTHER_MAJOR }} branch + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ env.OTHER_MAJOR }}.x + path: vortex-v${{ env.OTHER_MAJOR }} + persist-credentials: false + + - name: Build v${{ env.OTHER_MAJOR }} installer + run: | + composer install + sed -i "s/\"vortex-installer-version\": \"development\"/\"vortex-installer-version\": \"${OTHER_MAJOR}.x-dev\"/g" box.json + composer build + ./build/installer.phar --version + working-directory: vortex-v${{ env.OTHER_MAJOR }}/.vortex/installer + + - name: Upload v${{ env.OTHER_MAJOR }} installer artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: vortex-installer-v${{ env.OTHER_MAJOR }} + path: vortex-v${{ env.OTHER_MAJOR }}/.vortex/installer/build/installer.phar + if-no-files-found: error + vortex-release-docs: needs: vortex-release-installer @@ -109,6 +142,10 @@ jobs: run: working-directory: docs + env: + CURRENT_MAJOR: ${{ vars.VORTEX_CURRENT_MAJOR || '1' }} + OTHER_MAJOR: ${{ (vars.VORTEX_CURRENT_MAJOR || '1') == '1' && '2' || '1' }} + steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -134,14 +171,33 @@ jobs: with: php-version: 8.3 - - name: Download installer + - name: Download v1 installer uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: vortex-installer + name: vortex-installer-v1 + path: installer-v1 - - name: Copy installer to docs + - name: Download v2 installer + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: vortex-installer-v2 + path: installer-v2 + + # Publish both installers. '/v1/install' and '/v2/install' are the stable + # per-major pins, always built from each major's own source. The bare + # '/install' is a copy of the current major's pin, selected by the + # 'VORTEX_CURRENT_MAJOR' repository variable (default 1) - bumping that one + # variable is the only action needed to promote a new major. + - name: Copy installers to docs run: | - cp ../installer.phar ../.vortex/docs/static/install + case "${CURRENT_MAJOR}" in + 1|2) ;; + *) echo "Invalid VORTEX_CURRENT_MAJOR='${CURRENT_MAJOR}'. Expected 1 or 2."; exit 1 ;; + esac + mkdir -p ../.vortex/docs/static/v1 ../.vortex/docs/static/v2 + cp ../installer-v1/installer.phar ../.vortex/docs/static/v1/install + cp ../installer-v2/installer.phar ../.vortex/docs/static/v2/install + cp "../.vortex/docs/static/v${CURRENT_MAJOR}/install" ../.vortex/docs/static/install php ../.vortex/docs/static/install --version - name: Check docs up-to-date @@ -154,22 +210,25 @@ jobs: run: yarn install --frozen-lockfile working-directory: .vortex/docs - # Assemble the multi-version site for production: snapshot the released - # docs as v1 (served at the bare '/docs') and pull the '2.x' - # docs in as v2 (at '/docs/v2'). Mirrors the same step in - # 'vortex-test-docs.yml' (development / Netlify). + # Assemble the multi-version site for production: snapshot this ref's docs + # as the current major (served at the bare '/docs') and pull the other + # major's '{N}.x' branch docs in at '/docs/v{other}'. All driven by + # 'VORTEX_CURRENT_MAJOR'. Mirrors the same step in 'vortex-test-docs.yml' + # (development / Netlify). - name: Assemble versioned docs run: | - yarn docusaurus docs:version 1.x - git -C "${{ github.workspace }}" fetch origin 2.x --depth=1 || { echo "Failed to fetch the 2.x branch."; exit 1; } - git -C "${{ github.workspace }}" rev-parse --verify origin/2.x || { echo "The 2.x branch does not exist."; exit 1; } + yarn docusaurus docs:version "${CURRENT_MAJOR}.x" + git -C "${{ github.workspace }}" fetch origin "${OTHER_MAJOR}.x" --depth=1 || { echo "Failed to fetch the ${OTHER_MAJOR}.x branch."; exit 1; } + git -C "${{ github.workspace }}" rev-parse --verify "origin/${OTHER_MAJOR}.x" || { echo "The ${OTHER_MAJOR}.x branch does not exist."; exit 1; } rm -rf content - git -C "${{ github.workspace }}" checkout origin/2.x -- .vortex/docs/content || { echo "Failed to check out content from 2.x."; exit 1; } + git -C "${{ github.workspace }}" checkout "origin/${OTHER_MAJOR}.x" -- .vortex/docs/content || { echo "Failed to check out content from ${OTHER_MAJOR}.x."; exit 1; } working-directory: .vortex/docs - name: Build documentation site run: yarn run build working-directory: .vortex/docs + env: + VORTEX_CURRENT_MAJOR: ${{ env.CURRENT_MAJOR }} - name: Generate video for installer (not used in the final artifact) run: | diff --git a/.github/workflows/vortex-test-docs.yml b/.github/workflows/vortex-test-docs.yml index b98016932..680595502 100644 --- a/.github/workflows/vortex-test-docs.yml +++ b/.github/workflows/vortex-test-docs.yml @@ -19,6 +19,10 @@ jobs: statuses: write # Post pending/final commit statuses via 'gh api repos/.../statuses/...'. pull-requests: write # Post the Netlify preview link comment on the originating PR. + env: + CURRENT_MAJOR: ${{ vars.VORTEX_CURRENT_MAJOR || '1' }} + OTHER_MAJOR: ${{ (vars.VORTEX_CURRENT_MAJOR || '1') == '1' && '2' || '1' }} + steps: # Post pending status to the PR commit. # Workflows triggered by workflow_run don't automatically report status @@ -88,23 +92,55 @@ jobs: working-directory: '${{ github.workspace }}/.vortex/docs' # On the main (development) deploy, assemble the multi-version site: - # snapshot main's docs as v1 (served at the bare '/docs') and pull the - # '2.x' docs in as v2 (at '/docs/v2'). Skipped on PR/branch - # builds, which stay single-version previews. Mirrors the same step in - # 'vortex-release.yml' (production / GitHub Pages). + # snapshot main's docs as the current major (served at the bare '/docs') + # and pull the other major's '{N}.x' branch docs in at '/docs/v{other}'. + # Driven by 'VORTEX_CURRENT_MAJOR'. Skipped on PR/branch builds, which stay + # single-version previews. Mirrors the same step in 'vortex-release.yml' + # (production / GitHub Pages). - name: Assemble versioned docs if: github.event.workflow_run.head_branch == 'main' run: | - yarn docusaurus docs:version 1.x - git -C "${{ github.workspace }}" fetch origin 2.x --depth=1 || { echo "Failed to fetch the 2.x branch."; exit 1; } - git -C "${{ github.workspace }}" rev-parse --verify origin/2.x || { echo "The 2.x branch does not exist."; exit 1; } + yarn docusaurus docs:version "${CURRENT_MAJOR}.x" + git -C "${{ github.workspace }}" fetch origin "${OTHER_MAJOR}.x" --depth=1 || { echo "Failed to fetch the ${OTHER_MAJOR}.x branch."; exit 1; } + git -C "${{ github.workspace }}" rev-parse --verify "origin/${OTHER_MAJOR}.x" || { echo "The ${OTHER_MAJOR}.x branch does not exist."; exit 1; } rm -rf content - git -C "${{ github.workspace }}" checkout origin/2.x -- .vortex/docs/content || { echo "Failed to check out content from 2.x."; exit 1; } + git -C "${{ github.workspace }}" checkout "origin/${OTHER_MAJOR}.x" -- .vortex/docs/content || { echo "Failed to check out content from ${OTHER_MAJOR}.x."; exit 1; } working-directory: '${{ github.workspace }}/.vortex/docs' + # On the main deploy, publish both installers to match the multi-version + # docs: '/v1/install' and '/v2/install' are the stable per-major pins and + # the bare '/install' is a copy of the current major's pin (the + # 'VORTEX_CURRENT_MAJOR' repository variable, default 1). The downloaded + # installer above is the current major (built from 'main'); the other + # major is built from its '{N}.x' branch. Mirrors 'vortex-release.yml'. + - name: Checkout v${{ env.OTHER_MAJOR }} branch + if: github.event.workflow_run.head_branch == 'main' + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ env.OTHER_MAJOR }}.x + path: vortex-v${{ env.OTHER_MAJOR }} + persist-credentials: false + + - name: Build and publish v${{ env.OTHER_MAJOR }} installer + if: github.event.workflow_run.head_branch == 'main' + run: | + case "${CURRENT_MAJOR}" in + 1|2) ;; + *) echo "Invalid VORTEX_CURRENT_MAJOR='${CURRENT_MAJOR}'. Expected 1 or 2."; exit 1 ;; + esac + composer --working-dir="vortex-v${OTHER_MAJOR}/.vortex/installer" install + sed -i "s/\"vortex-installer-version\": \"development\"/\"vortex-installer-version\": \"${OTHER_MAJOR}.x-dev\"/g" "vortex-v${OTHER_MAJOR}/.vortex/installer/box.json" + composer --working-dir="vortex-v${OTHER_MAJOR}/.vortex/installer" build + mkdir -p .vortex/docs/static/v1 .vortex/docs/static/v2 + cp .vortex/docs/static/install ".vortex/docs/static/v${CURRENT_MAJOR}/install" + cp "vortex-v${OTHER_MAJOR}/.vortex/installer/build/installer.phar" ".vortex/docs/static/v${OTHER_MAJOR}/install" + cp ".vortex/docs/static/v${CURRENT_MAJOR}/install" .vortex/docs/static/install + - name: Build documentation site run: yarn run build working-directory: '${{ github.workspace }}/.vortex/docs' + env: + VORTEX_CURRENT_MAJOR: ${{ env.CURRENT_MAJOR }} # This workflow runs via 'workflow_run', where 'github.ref' is always the # default branch, so the action's branch-based production detection cannot diff --git a/.vortex/docs/.gitignore b/.vortex/docs/.gitignore index 88b8f32d2..7fa5d971f 100644 --- a/.vortex/docs/.gitignore +++ b/.vortex/docs/.gitignore @@ -28,5 +28,7 @@ npm-debug.log* yarn-debug.log* yarn-error.log* -# Copied installer file. +# Copied installer files (the bare active-major path and the per-major pins). static/install +static/v1/install +static/v2/install diff --git a/.vortex/docs/content/contributing/maintenance/release.mdx b/.vortex/docs/content/contributing/maintenance/release.mdx index d0c9f5187..2995e1805 100644 --- a/.vortex/docs/content/contributing/maintenance/release.mdx +++ b/.vortex/docs/content/contributing/maintenance/release.mdx @@ -43,13 +43,15 @@ When creating a new release, determine the version based on the changes: ### Branch strategy -- **`main`** - Current stable release branch (always the latest major version) -- **`project/X.x`** - Development branch for the next major version (e.g., `project/2.x`) -- **`X.x`** - Maintenance branches for older major versions (e.g., `1.x` after `2.x` is released) +- **`main`** - the current major version under active development (`1.x` today) +- **`2.x`** - a development branch for an upcoming major version, ahead of `main` (future majors follow the same pattern: `3.x`, `4.x`, ...) +- **`1.x`** - a maintenance branch for a superseded major version, behind `main` (for example `1.x` once `main` holds `2.x`) -When a new major version is ready: -1. The next major version development in `project/X.x` is merged into `main` -2. The previous major version code is preserved in its own `X.x` branch for maintenance +Promoting a new major version is a single action: set the `VORTEX_CURRENT_MAJOR` repository variable to its number. The documentation default and the installer's bare `/install` URL both follow it, while the per-major docs and `/v{N}/install` pins are always published. + +When `main` advances to a new major version: +1. The upcoming major's branch (for example `2.x`) is merged into `main` +2. The superseded major continues on its own branch (for example `1.x`) for maintenance :::note diff --git a/.vortex/docs/content/installation.mdx b/.vortex/docs/content/installation.mdx index 002aadc08..286526e39 100644 --- a/.vortex/docs/content/installation.mdx +++ b/.vortex/docs/content/installation.mdx @@ -44,6 +44,19 @@ curl -SsL https://www.vortextemplate.com/install > installer.php && php installe ::: +:::tip Choosing a Vortex version + +The command above installs the current stable **Vortex** release (the `1.x` line +today). To target a specific major version explicitly, use its dedicated path: + +- `https://www.vortextemplate.com/v1/install` - the current `1.x` line. +- `https://www.vortextemplate.com/v2/install` - the `2.x` line, currently in development. + +Once installed, `ahoy update-vortex` keeps your project on its major version - it +never jumps across a major release. See [Updating Vortex](./updating-vortex). + +::: + ### 2. Commit the initial project structure ```shell diff --git a/.vortex/docs/content/updating-vortex.mdx b/.vortex/docs/content/updating-vortex.mdx index 34847ea18..7faa6fe3f 100644 --- a/.vortex/docs/content/updating-vortex.mdx +++ b/.vortex/docs/content/updating-vortex.mdx @@ -21,6 +21,16 @@ After the update, review and commit the changes to your project to maintain vers We recommend updating **Vortex** monthly or at least quarterly to keep your project secure and up-to-date. +:::note Staying on your major version + +`ahoy update-vortex` updates your project **within its current major version** - a +`1.x` project receives the latest `1.x` release and is never moved to `2.x` +automatically. Moving to a new major is a deliberate step: run that major's +installer (for example `https://www.vortextemplate.com/v2/install`) against your +project. + +::: + ## Before updating Check the release notes for the latest version to understand what has changed diff --git a/.vortex/docs/docusaurus.config.js b/.vortex/docs/docusaurus.config.js index d1741b7ec..16d6f338a 100644 --- a/.vortex/docs/docusaurus.config.js +++ b/.vortex/docs/docusaurus.config.js @@ -18,6 +18,16 @@ import {themes as prismThemes} from 'prism-react-renderer'; // CI; it is never committed to a branch. const versioned = fs.existsSync('versioned_docs'); +// The current major (the 'VORTEX_CURRENT_MAJOR' repository variable, default 1) +// drives the whole site: its docs are a snapshot under 'versioned_docs/' served +// as the default at the bare '/docs', and the live 'content/' (pulled from the +// other major's '{N}.x' branch in CI) is served at '/docs/v{other}'. Bumping +// that one variable promotes a new major - nothing else changes here. +const currentMajor = process.env.VORTEX_CURRENT_MAJOR || '1'; +const otherMajor = currentMajor === '1' ? '2' : '1'; +const currentDocsVersion = `${currentMajor}.x`; +const otherIsNewer = Number(otherMajor) > Number(currentMajor); + /** @type {import('@docusaurus/types').Config} */ const config = { title: 'Vortex - Drupal project template', @@ -58,22 +68,20 @@ const config = { // Please change this to your repo. // Remove this to remove the "edit this page" links. editUrl: 'https://github.com/drevops/vortex/tree/main/.vortex/docs/', - // In versioned (aggregate) builds, v1 is the snapshot in - // 'versioned_docs/' served at the bare '/docs' (the default), and - // the current 'content/' is v2 at '/docs/v2'. To flip the default - // when 2.x is ready: set `lastVersion: 'current'`, give '1.x' a - // `path: 'v1'`, drop the v2 `path` so 'current' serves at the bare - // '/docs', and swap the '/docs/v1' redirect below for '/docs/v2'. + // In versioned (aggregate) builds the current major is the snapshot + // in 'versioned_docs/' served at the bare '/docs' (the default), and + // the live 'content/' is the other major at '/docs/v{other}'. Both + // are derived from 'VORTEX_CURRENT_MAJOR' - no manual edits to flip. ...(versioned ? { - lastVersion: '1.x', + lastVersion: currentDocsVersion, versions: { - '1.x': { - label: 'v1', + [currentDocsVersion]: { + label: `v${currentMajor}`, }, current: { - label: 'v2', - path: 'v2', - banner: 'unreleased', + label: `v${otherMajor}`, + path: `v${otherMajor}`, + banner: otherIsNewer ? 'unreleased' : 'unmaintained', }, }, } : {}), @@ -233,11 +241,10 @@ const config = { '@docusaurus/plugin-client-redirects', { redirects: [ - // In versioned builds, 'v1' is the default at the bare '/docs', so - // its explicit path redirects there. When the default flips to - // 'v2', change this to redirect '/docs/v2' instead. + // The current major is the default at the bare '/docs', so its + // explicit '/docs/v{current}' path redirects there. ...(versioned ? [{ - from: '/docs/v1', + from: `/docs/v${currentMajor}`, to: '/docs', }] : []), { diff --git a/.vortex/installer/src/Command/InstallCommand.php b/.vortex/installer/src/Command/InstallCommand.php index 4d1f5151b..e8620f894 100644 --- a/.vortex/installer/src/Command/InstallCommand.php +++ b/.vortex/installer/src/Command/InstallCommand.php @@ -25,6 +25,7 @@ use DrevOps\VortexInstaller\Utils\FileManager; use DrevOps\VortexInstaller\Utils\OptionsResolver; use DrevOps\VortexInstaller\Utils\Tui; +use DrevOps\VortexInstaller\Utils\Version; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -186,6 +187,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->presenter->header($this->artifact, $this->getApplication()->getVersion()); + $this->assertMajorCompatibility(); + // Only validate if using custom repository or custom reference. if (!$this->artifact->isDefault()) { Task::action( @@ -218,7 +221,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int Task::action( label: 'Downloading Vortex', action: function (): string { - $version = $this->getRepositoryDownloader()->download($this->artifact, $this->config->get(Config::TMP)); + $release_prefix = Version::releasePrefix($this->getApplication()->getVersion()); + $version = $this->getRepositoryDownloader()->download($this->artifact, $this->config->get(Config::TMP), $release_prefix); $this->config->set(Config::VERSION, $version); return $version; }, @@ -388,6 +392,39 @@ protected function runBuildCommand(OutputInterface $output): bool { return $runner->getExitCode() === RunnerInterface::EXIT_SUCCESS; } + /** + * Refuse to operate across major versions. + * + * Each installer build serves a single major line. Updating an existing + * project of a different major would cross a breaking boundary, so stop and + * point the user at the matching installer instead. Fresh installs and + * projects whose major cannot be determined are treated as compatible. + * + * @throws \RuntimeException + * When the destination project's major differs from this installer's major. + */ + protected function assertMajorCompatibility(): void { + if (!$this->config->isVortexProject()) { + return; + } + + $installer_major = Version::major($this->getApplication()->getVersion()); + if ($installer_major === NULL) { + return; + } + + $project_major = Version::detectProjectMajor((string) $this->config->getDst()); + if ($project_major === NULL || $project_major === $installer_major) { + return; + } + + throw new \RuntimeException(sprintf( + 'This installer targets Vortex %1$d.x, but the destination is a Vortex %2$d.x project. Update it with the %2$d.x installer instead: https://www.vortextemplate.com/v%2$d/install', + $installer_major, + $project_major, + )); + } + /** * Clean up installer artifacts. */ diff --git a/.vortex/installer/src/Downloader/RepositoryDownloader.php b/.vortex/installer/src/Downloader/RepositoryDownloader.php index abb33378a..afcc07dc0 100644 --- a/.vortex/installer/src/Downloader/RepositoryDownloader.php +++ b/.vortex/installer/src/Downloader/RepositoryDownloader.php @@ -46,9 +46,9 @@ public function __construct( ) { } - public function download(Artifact $artifact, ?string $dst = NULL): string { + public function download(Artifact $artifact, ?string $dst = NULL, ?string $release_prefix = NULL): string { if ($artifact->isRemote()) { - $version = $this->downloadFromRemote($artifact, $dst); + $version = $this->downloadFromRemote($artifact, $dst, $release_prefix); } else { $version = $this->downloadFromLocal($artifact, $dst); @@ -97,7 +97,7 @@ public function validate(Artifact $artifact): void { } } - protected function downloadFromRemote(Artifact $artifact, ?string $destination): string { + protected function downloadFromRemote(Artifact $artifact, ?string $destination, ?string $release_prefix = NULL): string { if ($destination === NULL) { throw new \InvalidArgumentException('Destination cannot be null for remote downloads.'); } @@ -108,7 +108,15 @@ protected function downloadFromRemote(Artifact $artifact, ?string $destination): $version = $artifact->getRef(); if ($artifact->getRef() === RepositoryDownloader::REF_STABLE) { - $ref = $this->discoverLatestReleaseRemote($repo_url); + $ref = $this->discoverLatestReleaseRemote($repo_url, $release_prefix); + + if ($ref === NULL && $release_prefix !== NULL) { + // No tagged release exists yet for this major (for example, an + // unreleased newer major). Fall back to the major development branch + // (for example "2.x") so the line is still installable. + $ref = rtrim($release_prefix, '.') . '.x'; + $this->validateRemoteRefExists($repo_url, $ref); + } if ($ref === NULL) { throw new \RuntimeException(sprintf('Unable to discover the latest release for "%s".', $repo_url)); diff --git a/.vortex/installer/src/Downloader/RepositoryDownloaderInterface.php b/.vortex/installer/src/Downloader/RepositoryDownloaderInterface.php index 2e4dc3a42..1d35425f1 100644 --- a/.vortex/installer/src/Downloader/RepositoryDownloaderInterface.php +++ b/.vortex/installer/src/Downloader/RepositoryDownloaderInterface.php @@ -17,6 +17,10 @@ interface RepositoryDownloaderInterface { * @param string|null $dst * The destination directory. If NULL, a temporary directory will be used * for local repositories. + * @param string|null $release_prefix + * When the reference is "stable", restrict release discovery to tags + * starting with this prefix (for example "1." for the 1.x line). If no + * matching release exists, the "{major}.x" development branch is used. * * @return string * The version/reference that was downloaded. @@ -26,6 +30,6 @@ interface RepositoryDownloaderInterface { * @throws \InvalidArgumentException * If the destination is null for remote downloads. */ - public function download(Artifact $artifact, ?string $dst = NULL): string; + public function download(Artifact $artifact, ?string $dst = NULL, ?string $release_prefix = NULL): string; } diff --git a/.vortex/installer/src/Utils/Version.php b/.vortex/installer/src/Utils/Version.php new file mode 100644 index 000000000..60be5ca28 --- /dev/null +++ b/.vortex/installer/src/Utils/Version.php @@ -0,0 +1,102 @@ +createMock(ExecutableFinder::class); + $executable_finder->method('find')->willReturnCallback(fn(string $command): string => '/usr/bin/' . $command); + + $install_command = new InstallCommand(); + $install_command->setExecutableFinder($executable_finder); + + if ($mock_download_fail) { + $mock_downloader = $this->createMock(RepositoryDownloader::class); + $mock_downloader->method('download')->willThrowException(new \RuntimeException('Failed to download Vortex.')); + $install_command->setRepositoryDownloader($mock_downloader); + } + + // Pre-populate the destination so it looks like an existing Vortex project: + // the README badge flags it as a Vortex project and the composer.json + // 'drevops/vortex-tooling' constraint carries the project's major. + $this->assertNotFalse(file_put_contents(self::$sut . '/README.md', '[![Vortex](https://img.shields.io/badge/Vortex-1.40.0-65ACBC.svg)](https://github.com/drevops/vortex)')); + $this->assertNotFalse(file_put_contents(self::$sut . '/composer.json', $composer_json)); + + static::applicationInitFromCommand($install_command); + + if ($version !== NULL) { + $this->applicationGet()->setVersion($version); + } + + $this->applicationRun([ + '--' . InstallCommand::OPTION_NO_INTERACTION => TRUE, + '--' . InstallCommand::OPTION_URI => File::dir(static::$root), + '--' . InstallCommand::OPTION_DESTINATION => self::$sut, + ], [], TRUE); + + $this->assertApplicationAnyOutputContainsOrNot($expected_message); + } + + /** + * Data provider for testInstallCommandMajorGate(). + * + * @return \Iterator + * Test data. + */ + public static function dataProviderInstallCommandMajorGate(): \Iterator { + yield 'v1 installer refuses v2 project' => [ + '1.40.0', + '{"require": {"drevops/vortex-tooling": "^2.0.0"}}', + FALSE, + 'https://www.vortextemplate.com/v2/install', + ]; + yield 'v1 installer accepts v1 project' => [ + '1.40.0', + '{"require": {"drevops/vortex-tooling": "^1.1.0"}}', + TRUE, + 'Failed to download Vortex.', + ]; + yield 'unstamped installer skips gate' => [ + NULL, + '{"require": {"drevops/vortex-tooling": "^2.0.0"}}', + TRUE, + 'Failed to download Vortex.', + ]; + yield 'undetectable project major skips gate' => [ + '1.40.0', + '{"require": {"php": ">=8.3"}}', + TRUE, + 'Failed to download Vortex.', + ]; + } + } diff --git a/.vortex/installer/tests/Unit/Downloader/RepositoryDownloaderTest.php b/.vortex/installer/tests/Unit/Downloader/RepositoryDownloaderTest.php index 6a2638fe1..eeef6f88a 100644 --- a/.vortex/installer/tests/Unit/Downloader/RepositoryDownloaderTest.php +++ b/.vortex/installer/tests/Unit/Downloader/RepositoryDownloaderTest.php @@ -169,6 +169,60 @@ public function testDownloadFromRemoteWithGitSuffix(): void { $this->assertEquals('develop', $version); } + public function testDownloadStableWithReleasePrefixSelectsMajor(): void { + $release_json = (string) json_encode([ + ['tag_name' => '2.0.0', 'draft' => FALSE], + ['tag_name' => '1.40.0', 'draft' => FALSE], + ['tag_name' => '1.39.0', 'draft' => FALSE], + ]); + + $mock_http_client = $this->createMock(ClientInterface::class); + $mock_http_client->method('request')->willReturnCallback(function (string $method, string $url) use ($release_json): ResponseInterface { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $body = $this->createMock(StreamInterface::class); + $body->method('getContents')->willReturn(str_contains($url, '/releases') ? $release_json : 'ok'); + $response->method('getBody')->willReturn($body); + return $response; + }); + + $mock_archiver = $this->createMock(ArchiverInterface::class); + $mock_file_downloader = $this->createMockFileDownloader(); + $destination = self::$tmp . '/destination_' . uniqid(); + File::mkdir($destination); + File::dump($destination . '/composer.json', '{}'); + + $downloader = new RepositoryDownloader($mock_http_client, $mock_archiver, NULL, $mock_file_downloader); + $version = $downloader->download(Artifact::create('https://github.com/user/repo', 'stable'), $destination, '1.'); + $this->assertEquals('1.40.0', $version); + } + + public function testDownloadStableWithReleasePrefixFallsBackToBranch(): void { + $release_json = (string) json_encode([ + ['tag_name' => '1.40.0', 'draft' => FALSE], + ]); + + $mock_http_client = $this->createMock(ClientInterface::class); + $mock_http_client->method('request')->willReturnCallback(function (string $method, string $url) use ($release_json): ResponseInterface { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $body = $this->createMock(StreamInterface::class); + $body->method('getContents')->willReturn(str_contains($url, '/releases') ? $release_json : 'ok'); + $response->method('getBody')->willReturn($body); + return $response; + }); + + $mock_archiver = $this->createMock(ArchiverInterface::class); + $mock_file_downloader = $this->createMockFileDownloader(); + $destination = self::$tmp . '/destination_' . uniqid(); + File::mkdir($destination); + File::dump($destination . '/composer.json', '{}'); + + $downloader = new RepositoryDownloader($mock_http_client, $mock_archiver, NULL, $mock_file_downloader); + $version = $downloader->download(Artifact::create('https://github.com/user/repo', 'stable'), $destination, '2.'); + $this->assertEquals('2.x', $version); + } + #[DataProvider('dataProviderDownloadWithNullDestination')] public function testDownloadWithNullDestination(string $repo, string $expectedMessage): void { $downloader = new RepositoryDownloader(); diff --git a/.vortex/installer/tests/Unit/VersionTest.php b/.vortex/installer/tests/Unit/VersionTest.php new file mode 100644 index 000000000..25fee2290 --- /dev/null +++ b/.vortex/installer/tests/Unit/VersionTest.php @@ -0,0 +1,110 @@ +assertSame($expected, Version::major($version)); + } + + /** + * Data provider for testMajor(). + * + * @return \Iterator + * Test data. + */ + public static function dataProviderMajor(): \Iterator { + yield 'null' => [NULL, NULL]; + yield 'empty' => ['', NULL]; + yield 'develop' => ['develop', NULL]; + yield 'unstamped token' => ['@vortex-installer-version@', NULL]; + yield 'semver 1.x' => ['1.40.0', 1]; + yield 'semver with v prefix' => ['v1.2.3', 1]; + yield 'semver 2.x' => ['2.0.0', 2]; + yield 'dev branch' => ['2.x-dev', 2]; + yield 'semver+calver' => ['1.0.0+2025.11.0', 1]; + yield 'legacy calver' => ['25.10.0', 25]; + yield 'leading whitespace' => [' 1.0.0', 1]; + } + + #[DataProvider('dataProviderReleasePrefix')] + public function testReleasePrefix(?string $version, ?string $expected): void { + $this->assertSame($expected, Version::releasePrefix($version)); + } + + /** + * Data provider for testReleasePrefix(). + * + * @return \Iterator + * Test data. + */ + public static function dataProviderReleasePrefix(): \Iterator { + yield 'null' => [NULL, NULL]; + yield 'develop' => ['develop', NULL]; + yield 'major 1' => ['1.40.0', '1.']; + yield 'major 2 dev' => ['2.x-dev', '2.']; + yield 'legacy calver' => ['25.10.0', '25.']; + } + + #[DataProvider('dataProviderMajorFromConstraint')] + public function testMajorFromConstraint(?string $constraint, ?int $expected): void { + $this->assertSame($expected, Version::majorFromConstraint($constraint)); + } + + /** + * Data provider for testMajorFromConstraint(). + * + * @return \Iterator + * Test data. + */ + public static function dataProviderMajorFromConstraint(): \Iterator { + yield 'null' => [NULL, NULL]; + yield 'empty' => ['', NULL]; + yield 'no digits' => ['dev-main', NULL]; + yield 'caret 1' => ['^1.1.0', 1]; + yield 'caret 2' => ['^2.0.0', 2]; + yield 'tilde 2' => ['~2.0', 2]; + yield 'dev branch' => ['2.x-dev', 2]; + yield 'range' => ['>=1.2 <3.0', 1]; + } + + #[DataProvider('dataProviderDetectProjectMajor')] + public function testDetectProjectMajor(?string $composer_json, ?int $expected): void { + $dir = self::$tmp . '/project_' . uniqid(); + File::mkdir($dir); + + if ($composer_json !== NULL) { + File::dump($dir . '/composer.json', $composer_json); + } + + $this->assertSame($expected, Version::detectProjectMajor($dir)); + } + + /** + * Data provider for testDetectProjectMajor(). + * + * @return \Iterator + * Test data. + */ + public static function dataProviderDetectProjectMajor(): \Iterator { + yield 'no composer.json' => [NULL, NULL]; + yield 'invalid json' => ['not json', NULL]; + yield 'empty object' => ['{}', NULL]; + yield 'no require' => ['{"name": "test/test"}', NULL]; + yield 'no tooling' => ['{"require": {"php": ">=8.3"}}', NULL]; + yield 'tooling v1' => ['{"require": {"drevops/vortex-tooling": "^1.1.0"}}', 1]; + yield 'tooling v2' => ['{"require": {"drevops/vortex-tooling": "^2.0.0"}}', 2]; + yield 'tooling non-string' => ['{"require": {"drevops/vortex-tooling": 1}}', NULL]; + } + +} diff --git a/.vortex/tooling/src/update-vortex b/.vortex/tooling/src/update-vortex index d4cdc0f4d..e16e25434 100755 --- a/.vortex/tooling/src/update-vortex +++ b/.vortex/tooling/src/update-vortex @@ -29,7 +29,11 @@ set -eu VORTEX_INSTALLER_TEMPLATE_REPO="${VORTEX_INSTALLER_TEMPLATE_REPO:-https://github.com/drevops/vortex.git#stable}" # The URL of the installer script. -VORTEX_INSTALLER_URL="${VORTEX_INSTALLER_URL:-https://www.vortextemplate.com/install}" +# +# Pinned to the major-specific path so an existing project always updates +# within its own major line. The bare '/install' tracks the active major and +# would jump a project across a major boundary once the next major is released. +VORTEX_INSTALLER_URL="${VORTEX_INSTALLER_URL:-https://www.vortextemplate.com/v1/install}" # Cache busting parameter for the installer URL. VORTEX_INSTALLER_URL_CACHE_BUST="${VORTEX_INSTALLER_URL_CACHE_BUST:-"$(date +%s)"}" diff --git a/.vortex/tooling/tests/unit/update-vortex.bats b/.vortex/tooling/tests/unit/update-vortex.bats index 42e4fcb6f..4c094877e 100644 --- a/.vortex/tooling/tests/unit/update-vortex.bats +++ b/.vortex/tooling/tests/unit/update-vortex.bats @@ -18,9 +18,9 @@ load ../_helper.bash # Test default values when no environment variables are set. declare -a STEPS=( - "@curl -fsSL https://www.vortextemplate.com/install?1234567890 -o installer.php # 0" + "@curl -fsSL https://www.vortextemplate.com/v1/install?1234567890 -o installer.php # 0" "@php installer.php --no-interaction --uri=https://github.com/drevops/vortex.git\#stable # 0" - "Using installer script from URL: https://www.vortextemplate.com/install" + "Using installer script from URL: https://www.vortextemplate.com/v1/install" "Downloading installer to installer.php" ) @@ -42,9 +42,9 @@ load ../_helper.bash export VORTEX_INSTALLER_URL_CACHE_BUST="1234567890" declare -a STEPS=( - "@curl -fsSL https://www.vortextemplate.com/install?1234567890 -o installer.php # 0" + "@curl -fsSL https://www.vortextemplate.com/v1/install?1234567890 -o installer.php # 0" "@php installer.php --no-interaction --uri=https://github.com/custom/repo.git\#main # 0" - "Using installer script from URL: https://www.vortextemplate.com/install" + "Using installer script from URL: https://www.vortextemplate.com/v1/install" "Downloading installer to installer.php" ) @@ -117,9 +117,9 @@ load ../_helper.bash export VORTEX_INSTALLER_URL_CACHE_BUST="1234567890" declare -a STEPS=( - "@curl -fsSL https://www.vortextemplate.com/install?1234567890 -o installer.php # 0" + "@curl -fsSL https://www.vortextemplate.com/v1/install?1234567890 -o installer.php # 0" "@php installer.php --no-interaction --uri=file:///local/path/to/vortex # 0" - "Using installer script from URL: https://www.vortextemplate.com/install" + "Using installer script from URL: https://www.vortextemplate.com/v1/install" "Downloading installer to installer.php" ) @@ -141,9 +141,9 @@ load ../_helper.bash export VORTEX_INSTALLER_URL_CACHE_BUST="1234567890" declare -a STEPS=( - "@curl -fsSL https://www.vortextemplate.com/install?1234567890 -o installer.php # 0" + "@curl -fsSL https://www.vortextemplate.com/v1/install?1234567890 -o installer.php # 0" "@php installer.php --no-interaction --uri=/local/path/to/vortex\#stable # 0" - "Using installer script from URL: https://www.vortextemplate.com/install" + "Using installer script from URL: https://www.vortextemplate.com/v1/install" "Downloading installer to installer.php" ) @@ -165,9 +165,9 @@ load ../_helper.bash export VORTEX_INSTALLER_URL_CACHE_BUST="1234567890" declare -a STEPS=( - "@curl -fsSL https://www.vortextemplate.com/install?1234567890 -o installer.php # 0" + "@curl -fsSL https://www.vortextemplate.com/v1/install?1234567890 -o installer.php # 0" "@php installer.php --no-interaction --uri=git@github.com:drevops/vortex.git\#v1.2.3 # 0" - "Using installer script from URL: https://www.vortextemplate.com/install" + "Using installer script from URL: https://www.vortextemplate.com/v1/install" "Downloading installer to installer.php" ) @@ -189,9 +189,9 @@ load ../_helper.bash export VORTEX_INSTALLER_URL_CACHE_BUST="1234567890" declare -a STEPS=( - "@curl -fsSL https://www.vortextemplate.com/install?1234567890 -o installer.php # 0" + "@curl -fsSL https://www.vortextemplate.com/v1/install?1234567890 -o installer.php # 0" "@php installer.php --no-interaction --uri=https://github.com/drevops/vortex.git\#stable # 1" - "Using installer script from URL: https://www.vortextemplate.com/install" + "Using installer script from URL: https://www.vortextemplate.com/v1/install" "Downloading installer to installer.php" ) @@ -213,9 +213,9 @@ load ../_helper.bash export VORTEX_INSTALLER_URL_CACHE_BUST="1234567890" declare -a STEPS=( - "@curl -fsSL https://www.vortextemplate.com/install?1234567890 -o installer.php # 0" + "@curl -fsSL https://www.vortextemplate.com/v1/install?1234567890 -o installer.php # 0" "@php installer.php --uri=https://github.com/drevops/vortex.git\#stable # 0" - "Using installer script from URL: https://www.vortextemplate.com/install" + "Using installer script from URL: https://www.vortextemplate.com/v1/install" "Downloading installer to installer.php" ) @@ -237,9 +237,9 @@ load ../_helper.bash export VORTEX_INSTALLER_URL_CACHE_BUST="1234567890" declare -a STEPS=( - "@curl -fsSL https://www.vortextemplate.com/install?1234567890 -o installer.php # 0" + "@curl -fsSL https://www.vortextemplate.com/v1/install?1234567890 -o installer.php # 0" "@php installer.php --uri=https://github.com/custom/repo.git\#main # 0" - "Using installer script from URL: https://www.vortextemplate.com/install" + "Using installer script from URL: https://www.vortextemplate.com/v1/install" "Downloading installer to installer.php" )