diff --git a/.github/workflows/e2e-publishing.yml b/.github/workflows/e2e-publishing.yml index 35c1596c..f85b5bd1 100644 --- a/.github/workflows/e2e-publishing.yml +++ b/.github/workflows/e2e-publishing.yml @@ -175,6 +175,94 @@ jobs: throw "settings.xml file is not found in expected location" } + test-maven-mirrors: + name: Maven mirrors (${{ matrix.mirror-of }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest, ubuntu-latest] + mirror-of: ['*', 'external:*'] + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Configure Maven mirrors without repository inputs + uses: ./ + with: + distribution: 'temurin' + java-version: '21' + settings-path: ${{ runner.temp }}/maven-mirror-test + mvn-server-credentials: | + private:PRIVATE_USERNAME:PRIVATE_TOKEN + mvn-mirrors: | + private:https://mirror.example.invalid/maven:${{ matrix.mirror-of }} + - name: Validate generated settings and Maven mirror selection + env: + MIRROR_OF: ${{ matrix.mirror-of }} + PRIVATE_USERNAME: mirror-test-user + PRIVATE_TOKEN: mirror-test-token + run: | + $directory = Join-Path $env:RUNNER_TEMP "maven-mirror-test" + $settingsPath = Join-Path $directory "settings.xml" + [xml]$settings = Get-Content -Path $settingsPath -Raw + $mirrors = @($settings.settings.mirrors.mirror) + if ($mirrors.Count -ne 1 -or $mirrors[0].id -ne "private" -or $mirrors[0].url -ne "https://mirror.example.invalid/maven" -or $mirrors[0].mirrorOf -ne $env:MIRROR_OF) { + throw "Incorrect generated mirror" + } + if ($settings.settings.profiles -or $settings.settings.activeProfiles) { + throw "Mirror-only settings must not add repository profiles" + } + $server = $settings.settings.servers.server + if ($server.id -ne "private" -or $server.username -ne '${env.PRIVATE_USERNAME}' -or $server.password -ne '${env.PRIVATE_TOKEN}') { + throw "Mirror credentials were not preserved" + } + + $pomPath = Join-Path $directory "pom.xml" + @' + + 4.0.0 + setup-java.test + mirror-settings + 1.0 + + + pom-dependencies + https://dependencies.example.invalid/maven + + + + + pom-plugins + https://plugins.example.invalid/maven + + + + '@ | Set-Content -Path $pomPath + + # validate requires no plugins or dependencies, so mirror selection can + # be inspected offline without contacting the deliberately invalid URLs. + $output = & mvn --batch-mode --no-transfer-progress --offline --debug ` + --settings $settingsPath --file $pomPath ` + "-Dmaven.repo.local=$directory/repository" validate 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Could not inspect Maven mirror selection: $output" + } + $log = $output -join "`n" + foreach ($id in @("central", "pom-dependencies")) { + $expected = "Using mirror private (https://mirror.example.invalid/maven) for $id (" + if (-not $log.Contains($expected)) { + throw "Maven did not select the mirror for ${id}: $log" + } + } + foreach ($type in @("dependencies", "plugins")) { + $pattern = 'Repositories \(' + $type + '\)\s*: \[private \(https://mirror\.example\.invalid/maven, [^\]]+\)\]' + if ($log -notmatch $pattern) { + throw "Maven did not redirect all $type repositories: $log" + } + } + test-maven-repositories: name: Maven ${{ matrix.repositories }} repositories runs-on: ${{ matrix.os }} diff --git a/README.md b/README.md index 58822e68..b3740931 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,7 @@ steps: | `mvn-server-repository-origins` | Multiline Maven credential origins in the format `server-id:repository-origin`. Each origin must belong to a configured server ID. Requires Maven 3.10+. | | | `mvn-repositories` | Multiline Maven dependency repositories in the format `repository-id:repository-url:snapshots-enabled`. | | | `mvn-plugin-repositories` | Multiline Maven plugin repositories in the format `repository-id:repository-url:snapshots-enabled`. Can be used independently of `mvn-repositories`. | | +| `mvn-mirrors` | Multiline Maven mirrors in the format `mirror-id:mirror-url:mirror-of`. Redirect matching dependency and plugin repositories; use a matching server ID for authentication. | | | `mvn-repositories-include-central` | Include Maven Central for dependency and plugin repositories in the generated profile. When `false`, Central is disabled for both types unless that type explicitly declares `central`. Has no effect when neither repository list is configured. | `true` | | `mvn-repositories-prioritize-central` | Place Maven Central before custom dependency and plugin repositories. Has no effect when Central is excluded or neither repository list is configured. | `true` | | `settings-path` | Directory where `settings.xml` is written. | `~/.m2` | @@ -490,6 +491,11 @@ match `mvn-server-credentials` IDs when authentication is required. See [Resolving Maven dependencies from custom repositories](docs/advanced-usage.md#resolving-maven-dependencies-from-custom-repositories) and [Resolving Maven plugins from custom repositories](docs/advanced-usage.md#resolving-maven-plugins-from-custom-repositories). +To redirect repositories, including Central and repositories declared in project +or parent POMs, use [`mvn-mirrors`](docs/advanced-usage.md#redirecting-maven-repositories-with-mirrors). +It works independently of the repository inputs; a `mirrorOf` pattern of `*` +routes all dependency and plugin repositories through the configured mirror. + > [!WARNING] > `mvn-repositories-include-central: false` now disables inherited Maven Central > for **both dependencies and plugins**, even in existing workflows that only diff --git a/__tests__/auth.test.ts b/__tests__/auth.test.ts index 7af82c8f..4500de15 100644 --- a/__tests__/auth.test.ts +++ b/__tests__/auth.test.ts @@ -882,6 +882,162 @@ describe('auth tests', () => { ]); }); + it.each([ + '*', + 'central', + 'external:*', + 'external:http:*', + '*,!internal', + 'external:*,!internal', + '!internal,external:http:*', + 'external:*,!external:http:*' + ])('preserves the mirror selector %s and URL colons', mirrorOf => { + expect( + auth.parseMavenMirrors([ + '', + ' \t ', + ` private : https://[2001:db8::1]:8443/maven : ${mirrorOf} `, + 'public:https://repo.example.com:443/releases:central' + ]) + ).toEqual([ + { + id: 'private', + url: 'https://[2001:db8::1]:8443/maven', + mirrorOf + }, + { + id: 'public', + url: 'https://repo.example.com:443/releases', + mirrorOf: 'central' + } + ]); + }); + + it.each([ + 'private', + 'private:https://repo.example.com', + ':https://repo.example.com:*', + 'private::*', + 'private:https://repo.example.com:', + 'private: :*', + 'private:https://repo.example.com: ' + ])('rejects malformed mirror entry %s with a line number', entry => { + expect(() => auth.parseMavenMirrors(['', entry])).toThrow( + 'Invalid mvn-mirrors entry at line 2.' + ); + }); + + it('rejects duplicate mirror IDs after trimming', () => { + expect(() => + auth.parseMavenMirrors([ + 'private:https://first.example.com:*', + ' private :https://second.example.com:central' + ]) + ).toThrow("Duplicate mirror-id 'private' in mvn-mirrors input at line 2"); + }); + + it('leaves settings unchanged for absent or blank mirrors', () => { + const servers = credentials('github', 'USERNAME', 'PASSWORD'); + expect(auth.parseMavenMirrors(['', ' ', '\t'])).toEqual([]); + expect(auth.generate(servers, undefined, undefined, [])).toBe( + auth.generate(servers) + ); + expect(auth.generate(servers)).not.toContain(''); + }); + + it('generates public mirrors without credentials or repository profiles', () => { + const mirrors = auth.parseMavenMirrors([ + 'public:https://repo.example.com:external:*', + 'fallback:https://other.example.com:central' + ]); + const xml = auth.generate([], undefined, undefined, mirrors); + expect(parseXmlObject(xml)).toMatchObject({ + settings: {mirrors: {mirror: mirrors}} + }); + expect(xml).not.toContain(''); + expect(xml).not.toContain(''); + }); + + it('escapes all mirror fields as XML text', () => { + const mirror = { + id: 'private<&>', + url: 'https://repo.example.com/maven?a=1&b=', + mirrorOf: '*,!internal<&>' + }; + const xml = auth.generate([], undefined, undefined, [mirror]); + expect(xml).toContain('private<&>'); + expect(xml).toContain('a=1&b=<value>'); + expect(xml).toContain('*,!internal<&>'); + expect(parseXmlObject(xml)).toMatchObject({ + settings: {mirrors: {mirror}} + }); + }); + + it.each([ + {combined: false, overwrite: true}, + {combined: true, overwrite: true}, + {combined: true, overwrite: false} + ])( + 'configures mirrors with combined=$combined and overwrite=$overwrite', + async ({combined, overwrite}) => { + const directory = path.join(m2Dir, 'mirrors'); + const location = path.join(directory, MVN_SETTINGS_FILE); + fs.mkdirSync(directory, {recursive: true}); + fs.writeFileSync(location, 'EXISTING SETTINGS'); + const url = 'https://maven.example.com/releases'; + const inputs: Record = { + 'settings-path': directory, + 'overwrite-settings': String(overwrite), + 'mvn-repositories-include-central': 'false', + ...(combined ? {'gpg-passphrase-env-var': 'GPG_PASSPHRASE'} : {}) + }; + const multilineInputs: Record = { + 'mvn-server-credentials': ['private:PRIVATE_USERNAME:PRIVATE_TOKEN'], + 'mvn-mirrors': [`private:${url}:*`], + ...(combined + ? { + 'mvn-repositories': [`private:${url}:false`], + 'mvn-plugin-repositories': [`private:${url}:false`] + } + : {}) + }; + jest.mocked(core.getInput).mockImplementation(name => inputs[name] ?? ''); + jest + .mocked(core.getMultilineInput) + .mockImplementation(name => multilineInputs[name] ?? []); + + await auth.configureAuthentication(); + + const xml = fs.readFileSync(location, 'utf8'); + if (!overwrite) { + expect(xml).toBe('EXISTING SETTINGS'); + return; + } + expect(parseXmlObject(xml)).toMatchObject({ + settings: { + mirrors: {mirror: {id: 'private', url, mirrorOf: '*'}}, + servers: { + server: { + id: 'private', + username: '${env.PRIVATE_USERNAME}', + password: '${env.PRIVATE_TOKEN}' + } + } + } + }); + if (combined) { + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain('GPG_PASSPHRASE'); + } else { + expect(xml).not.toContain(''); + expect(core.getInput).not.toHaveBeenCalledWith( + 'mvn-repositories-include-central' + ); + } + } + ); + it.each(repositoryInputs)( 'parses %s while preserving colons in URLs', inputName => { diff --git a/action.yml b/action.yml index eb32ec42..adf85a59 100644 --- a/action.yml +++ b/action.yml @@ -75,6 +75,9 @@ inputs: mvn-plugin-repositories: description: 'Multiline list of Maven plugin repositories in the format `repository-id:repository-url:snapshots-enabled`.' required: false + mvn-mirrors: + description: 'Multiline list of Maven mirrors in the format `mirror-id:mirror-url:mirror-of`. Use a matching server ID for authentication. Can be used independently of repository inputs.' + required: false mvn-repositories-include-central: description: 'Include Maven Central for dependency and plugin repositories in the generated profile. When false, Central is disabled for both types unless that type explicitly declares ID `central`. Has no effect when neither repository list is configured.' required: false diff --git a/dist/cleanup/index.js b/dist/cleanup/index.js index 03ab6fe1..349e3d01 100644 --- a/dist/cleanup/index.js +++ b/dist/cleanup/index.js @@ -31040,7 +31040,7 @@ module.exports = { /* harmony export */ gk: () => (/* binding */ INPUT_CACHE), /* harmony export */ wG: () => (/* binding */ INPUT_JOB_STATUS) /* harmony export */ }); -/* unused harmony exports MACOS_JAVA_CONTENT_POSTFIX, INPUT_JAVA_VERSION, INPUT_JAVA_VERSION_FILE, INPUT_ARCHITECTURE, INPUT_JAVA_PACKAGE, INPUT_DISTRIBUTION, INPUT_JDK_FILE, INPUT_JDK_FILE_DEPRECATED, INPUT_CHECK_LATEST, INPUT_FORCE_DOWNLOAD, INPUT_SET_DEFAULT, INPUT_PROBLEM_MATCHER, INPUT_VERIFY_SIGNATURE, INPUT_VERIFY_SIGNATURE_PUBLIC_KEY, SIGNATURE_VERIFICATION_DOCUMENTATION_URL, SIGNATURE_VERIFICATION_FAILURE_HELP, INPUT_MVN_SERVER_CREDENTIALS, INPUT_MVN_SERVER_REPOSITORY_ORIGINS, INPUT_MVN_REPOSITORIES, INPUT_MVN_PLUGIN_REPOSITORIES, INPUT_MVN_REPOSITORIES_INCLUDE_CENTRAL, INPUT_MVN_REPOSITORIES_PRIORITIZE_CENTRAL, INPUT_SERVER_ID, INPUT_SERVER_USERNAME_ENV_VAR, INPUT_SERVER_PASSWORD_ENV_VAR, INPUT_SERVER_USERNAME_DEPRECATED, INPUT_SERVER_PASSWORD_DEPRECATED, INPUT_SETTINGS_PATH, INPUT_OVERWRITE_SETTINGS, INPUT_GPG_PRIVATE_KEY, INPUT_GPG_PASSPHRASE_ENV_VAR, INPUT_GPG_PASSPHRASE_DEPRECATED, INPUT_DEFAULT_SERVER_USERNAME, INPUT_DEFAULT_SERVER_PASSWORD, INPUT_DEFAULT_GPG_PRIVATE_KEY, INPUT_DEFAULT_GPG_PASSPHRASE, MAVEN_GPG_PASSPHRASE_DEFAULT_ENV, GPG_PASSPHRASE_PROFILE_ID, MAVEN_REPOSITORIES_PROFILE_ID, MAVEN_CENTRAL_REPOSITORY_ID, MAVEN_CENTRAL_REPOSITORY_URL, INPUT_CACHE_DEPENDENCY_PATH, INPUT_CACHE_PATH, M2_DIR, MVN_SETTINGS_FILE, MVN_TOOLCHAINS_FILE, INPUT_MVN_TOOLCHAIN_ID, INPUT_MVN_TOOLCHAIN_VENDOR, INPUT_SHOW_DOWNLOAD_PROGRESS, MAVEN_ARGS_ENV, MAVEN_NO_TRANSFER_PROGRESS_FLAG, MAVEN_NO_TRANSFER_PROGRESS_LONG_FLAG, DISTRIBUTIONS_ONLY_MAJOR_VERSION */ +/* unused harmony exports MACOS_JAVA_CONTENT_POSTFIX, INPUT_JAVA_VERSION, INPUT_JAVA_VERSION_FILE, INPUT_ARCHITECTURE, INPUT_JAVA_PACKAGE, INPUT_DISTRIBUTION, INPUT_JDK_FILE, INPUT_JDK_FILE_DEPRECATED, INPUT_CHECK_LATEST, INPUT_FORCE_DOWNLOAD, INPUT_SET_DEFAULT, INPUT_PROBLEM_MATCHER, INPUT_VERIFY_SIGNATURE, INPUT_VERIFY_SIGNATURE_PUBLIC_KEY, SIGNATURE_VERIFICATION_DOCUMENTATION_URL, SIGNATURE_VERIFICATION_FAILURE_HELP, INPUT_MVN_SERVER_CREDENTIALS, INPUT_MVN_SERVER_REPOSITORY_ORIGINS, INPUT_MVN_REPOSITORIES, INPUT_MVN_PLUGIN_REPOSITORIES, INPUT_MVN_MIRRORS, INPUT_MVN_REPOSITORIES_INCLUDE_CENTRAL, INPUT_MVN_REPOSITORIES_PRIORITIZE_CENTRAL, INPUT_SERVER_ID, INPUT_SERVER_USERNAME_ENV_VAR, INPUT_SERVER_PASSWORD_ENV_VAR, INPUT_SERVER_USERNAME_DEPRECATED, INPUT_SERVER_PASSWORD_DEPRECATED, INPUT_SETTINGS_PATH, INPUT_OVERWRITE_SETTINGS, INPUT_GPG_PRIVATE_KEY, INPUT_GPG_PASSPHRASE_ENV_VAR, INPUT_GPG_PASSPHRASE_DEPRECATED, INPUT_DEFAULT_SERVER_USERNAME, INPUT_DEFAULT_SERVER_PASSWORD, INPUT_DEFAULT_GPG_PRIVATE_KEY, INPUT_DEFAULT_GPG_PASSPHRASE, MAVEN_GPG_PASSPHRASE_DEFAULT_ENV, GPG_PASSPHRASE_PROFILE_ID, MAVEN_REPOSITORIES_PROFILE_ID, MAVEN_CENTRAL_REPOSITORY_ID, MAVEN_CENTRAL_REPOSITORY_URL, INPUT_CACHE_DEPENDENCY_PATH, INPUT_CACHE_PATH, M2_DIR, MVN_SETTINGS_FILE, MVN_TOOLCHAINS_FILE, INPUT_MVN_TOOLCHAIN_ID, INPUT_MVN_TOOLCHAIN_VENDOR, INPUT_SHOW_DOWNLOAD_PROGRESS, MAVEN_ARGS_ENV, MAVEN_NO_TRANSFER_PROGRESS_FLAG, MAVEN_NO_TRANSFER_PROGRESS_LONG_FLAG, DISTRIBUTIONS_ONLY_MAJOR_VERSION */ const MACOS_JAVA_CONTENT_POSTFIX = 'Contents/Home'; const INPUT_JAVA_VERSION = 'java-version'; const INPUT_JAVA_VERSION_FILE = 'java-version-file'; @@ -31061,6 +31061,7 @@ const INPUT_MVN_SERVER_CREDENTIALS = 'mvn-server-credentials'; const INPUT_MVN_SERVER_REPOSITORY_ORIGINS = 'mvn-server-repository-origins'; const INPUT_MVN_REPOSITORIES = 'mvn-repositories'; const INPUT_MVN_PLUGIN_REPOSITORIES = 'mvn-plugin-repositories'; +const INPUT_MVN_MIRRORS = 'mvn-mirrors'; const INPUT_MVN_REPOSITORIES_INCLUDE_CENTRAL = 'mvn-repositories-include-central'; const INPUT_MVN_REPOSITORIES_PRIORITIZE_CENTRAL = 'mvn-repositories-prioritize-central'; const INPUT_SERVER_ID = 'server-id'; diff --git a/dist/setup/81.index.js b/dist/setup/81.index.js index 39f98e37..28c50048 100644 --- a/dist/setup/81.index.js +++ b/dist/setup/81.index.js @@ -14,6 +14,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony export */ getInputWithDeprecatedAlias: () => (/* binding */ getInputWithDeprecatedAlias), /* harmony export */ getMavenRepositorySettings: () => (/* binding */ getMavenRepositorySettings), /* harmony export */ getMavenServerSettings: () => (/* binding */ getMavenServerSettings), +/* harmony export */ parseMavenMirrors: () => (/* binding */ parseMavenMirrors), /* harmony export */ parseMavenRepositories: () => (/* binding */ parseMavenRepositories), /* harmony export */ parseMavenServerCredentials: () => (/* binding */ parseMavenServerCredentials) /* harmony export */ }); @@ -42,6 +43,7 @@ const MAVEN_REPOSITORY_ORIGIN = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/?#@]+\/?$/; async function configureAuthentication() { const servers = getMavenServerSettings(); const repositorySettings = getMavenRepositorySettings(); + const mirrors = parseMavenMirrors(_actions_core__WEBPACK_IMPORTED_MODULE_1__/* .getMultilineInput */ .q3(_constants_js__WEBPACK_IMPORTED_MODULE_7__/* .INPUT_MVN_MIRRORS */ .K)); const settingsDirectory = _actions_core__WEBPACK_IMPORTED_MODULE_1__/* .getInput */ .V4(_constants_js__WEBPACK_IMPORTED_MODULE_7__/* .INPUT_SETTINGS_PATH */ .Xh) || path__WEBPACK_IMPORTED_MODULE_0__.join(os__WEBPACK_IMPORTED_MODULE_4__.homedir(), _constants_js__WEBPACK_IMPORTED_MODULE_7__/* .M2_DIR */ .iT); const overwriteSettings = (0,_util_js__WEBPACK_IMPORTED_MODULE_6__/* .getBooleanInput */ .Vt)(_constants_js__WEBPACK_IMPORTED_MODULE_7__/* .INPUT_OVERWRITE_SETTINGS */ .TS, true); @@ -51,7 +53,7 @@ async function configureAuthentication() { if (gpgPrivateKey) { _actions_core__WEBPACK_IMPORTED_MODULE_1__/* .setSecret */ .Pq(gpgPrivateKey); } - await createAuthenticationSettings(servers, settingsDirectory, overwriteSettings, gpgPassphraseEnvVar, repositorySettings); + await createAuthenticationSettings(servers, settingsDirectory, overwriteSettings, gpgPassphraseEnvVar, repositorySettings, mirrors); if (gpgPrivateKey) { _actions_core__WEBPACK_IMPORTED_MODULE_1__/* .info */ .pq('Importing private gpg key'); const gpgHome = await _gpg_js__WEBPACK_IMPORTED_MODULE_5__/* .importKey */ .Fh(gpgPrivateKey); @@ -222,6 +224,37 @@ function parseMavenRepositories(entries, includeCentral, inputName = _constants_ }); return repositories; } +// only exported for testing purposes +function parseMavenMirrors(entries) { + const mirrors = []; + const mirrorIds = new Set(); + entries.forEach((entry, index) => { + if (!entry.trim()) { + return; + } + const separator = entry.indexOf(':'); + const remainder = entry.slice(separator + 1).trim(); + // Consume the URL scheme before looking for the selector delimiter. Selector + // tokens may themselves contain colons (external:* and external:http:*). + const scheme = /^[A-Za-z][A-Za-z0-9+.-]*:/.exec(remainder)?.[0] ?? ''; + const fields = /^(.*?):((?:\s*!?external:(?:http:)?\*\s*|[^:,]*)(?:,(?:\s*!?external:(?:http:)?\*\s*|[^:,]*))*)$/.exec(remainder.slice(scheme.length)); + if (separator < 0 || !fields) { + throw new Error(`Invalid mvn-mirrors entry at line ${index + 1}. Expected format: mirror-id:mirror-url:mirror-of`); + } + const id = entry.slice(0, separator).trim(); + const url = `${scheme}${fields[1]}`.trim(); + const mirrorOf = fields[2].trim(); + if (!id || !url || !mirrorOf) { + throw new Error(`Invalid mvn-mirrors entry at line ${index + 1}. mirror-id, mirror URL, and mirror-of are required`); + } + if (mirrorIds.has(id)) { + throw new Error(`Duplicate mirror-id '${id}' in mvn-mirrors input at line ${index + 1}`); + } + mirrorIds.add(id); + mirrors.push({ id, url, mirrorOf }); + }); + return mirrors; +} function generateRepositorySection(customRepositories, settings, element) { if (!customRepositories.length && settings.includeCentral) { return []; @@ -253,15 +286,15 @@ function generateRepositorySection(customRepositories, settings, element) { lines.push(` `); return lines; } -async function createAuthenticationSettings(servers, settingsDirectory, overwriteSettings, gpgPassphraseEnvVar = undefined, repositorySettings = undefined) { +async function createAuthenticationSettings(servers, settingsDirectory, overwriteSettings, gpgPassphraseEnvVar = undefined, repositorySettings = undefined, mirrors = []) { _actions_core__WEBPACK_IMPORTED_MODULE_1__/* .info */ .pq(`Creating ${_constants_js__WEBPACK_IMPORTED_MODULE_7__/* .MVN_SETTINGS_FILE */ .vO} with server-id: ${servers.map(server => server.id).join(', ')}`); // when an alternate m2 location is specified use only that location (no .m2 directory) // otherwise use the home/.m2/ path await _actions_io__WEBPACK_IMPORTED_MODULE_2__/* .mkdirP */ .U$(settingsDirectory); - await write(settingsDirectory, generate(servers, gpgPassphraseEnvVar, repositorySettings), overwriteSettings); + await write(settingsDirectory, generate(servers, gpgPassphraseEnvVar, repositorySettings, mirrors), overwriteSettings); } // only exported for testing purposes -function generate(servers, gpgPassphraseEnvVar, repositorySettings) { +function generate(servers, gpgPassphraseEnvVar, repositorySettings, mirrors = []) { // The maven-gpg-plugin reads the passphrase from the environment variable // named by the `gpg.passphraseEnvName` property (default MAVEN_GPG_PASSPHRASE). // Only configure it when the requested env var name differs from that default; @@ -289,6 +322,13 @@ function generate(servers, gpgPassphraseEnvVar, repositorySettings) { lines.push(' '); } lines.push(' '); + if (mirrors.length) { + lines.push(' '); + for (const mirror of mirrors) { + lines.push(' ', ` ${(0,_xml_js__WEBPACK_IMPORTED_MODULE_8__/* .escapeXmlText */ .I)(mirror.id)}`, ` ${(0,_xml_js__WEBPACK_IMPORTED_MODULE_8__/* .escapeXmlText */ .I)(mirror.url)}`, ` ${(0,_xml_js__WEBPACK_IMPORTED_MODULE_8__/* .escapeXmlText */ .I)(mirror.mirrorOf)}`, ' '); + } + lines.push(' '); + } if (repositorySettings || includeGpgPassphraseProfile) { lines.push(' '); if (repositorySettings) { diff --git a/dist/setup/index.js b/dist/setup/index.js index 5c053664..bc543700 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -31040,6 +31040,7 @@ module.exports = { /* harmony export */ GL: () => (/* binding */ INPUT_CACHE_JDK), /* harmony export */ H5: () => (/* binding */ INPUT_MVN_REPOSITORIES_INCLUDE_CENTRAL), /* harmony export */ I9: () => (/* binding */ INPUT_FORCE_DOWNLOAD), +/* harmony export */ K: () => (/* binding */ INPUT_MVN_MIRRORS), /* harmony export */ K$: () => (/* binding */ GPG_PASSPHRASE_PROFILE_ID), /* harmony export */ LS: () => (/* binding */ INPUT_ARCHITECTURE), /* harmony export */ MM: () => (/* binding */ INPUT_MVN_SERVER_CREDENTIALS), @@ -31111,6 +31112,7 @@ const INPUT_MVN_SERVER_CREDENTIALS = 'mvn-server-credentials'; const INPUT_MVN_SERVER_REPOSITORY_ORIGINS = 'mvn-server-repository-origins'; const INPUT_MVN_REPOSITORIES = 'mvn-repositories'; const INPUT_MVN_PLUGIN_REPOSITORIES = 'mvn-plugin-repositories'; +const INPUT_MVN_MIRRORS = 'mvn-mirrors'; const INPUT_MVN_REPOSITORIES_INCLUDE_CENTRAL = 'mvn-repositories-include-central'; const INPUT_MVN_REPOSITORIES_PRIORITIZE_CENTRAL = 'mvn-repositories-prioritize-central'; const INPUT_SERVER_ID = 'server-id'; diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index dedca4ed..488d1efb 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -28,6 +28,7 @@ - [Testing against different platforms](#Testing-against-different-platforms) - [Publishing using Apache Maven](#Publishing-using-Apache-Maven) - [Publishing to multiple Maven servers](#publishing-to-multiple-maven-servers) +- [Redirecting Maven repositories with mirrors](#redirecting-maven-repositories-with-mirrors) - [Apache Maven with a settings path](#apache-maven-with-a-settings-path) - [Maven transfer progress (download logs)](#Maven-transfer-progress-download-logs) - [Java problem matcher (compiler annotations)](#java-problem-matcher-compiler-annotations) @@ -1090,6 +1091,72 @@ default; `mvn-repositories-prioritize-central: false` places it after them. All repository generation respects `settings-path` and `overwrite-settings`; an existing settings file is left untouched when `overwrite-settings: false`. +### Redirecting Maven repositories with mirrors + +Use `mvn-mirrors` to redirect matching dependency and plugin repositories, +including Maven Central and repositories declared in project or parent POMs. +Unlike repository profiles, mirrors replace the destination Maven contacts. +The Central controls described above do not redirect arbitrary POM repositories. + +Each nonblank line has the format `mirror-id:mirror-url:mirror-of`. IDs must be +unique within this input. URL schemes and ports, and colons in `mirrorOf` +selectors, are preserved. For example: + +```yaml +steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v6 + with: + distribution: 'temurin' + java-version: '21' + mvn-server-credentials: | + private:PRIVATE_REPOSITORY_USERNAME:PRIVATE_REPOSITORY_TOKEN + mvn-mirrors: | + private:https://maven.example.com/releases:* + - run: mvn --batch-mode verify + env: + PRIVATE_REPOSITORY_USERNAME: ${{ secrets.PRIVATE_REPOSITORY_USERNAME }} + PRIVATE_REPOSITORY_TOKEN: ${{ secrets.PRIVATE_REPOSITORY_TOKEN }} +``` + +This adds the following directly under ``, not inside a profile: + +```xml + + + private + https://maven.example.com/releases + * + + +``` + +For authentication, the mirror ID must match a server ID configured through +`mvn-server-credentials` or the single-server inputs. Public mirrors do not need +matching credentials. Credentials remain environment-variable references in the +generated settings; do not put credential values in `mvn-mirrors`. + +Mirrors can be used alone, as above, or alongside `mvn-repositories` and +`mvn-plugin-repositories`. They do not change those inputs or the Central +controls. Omitting `mvn-mirrors`, or supplying only blank lines, adds no +`` section or repository profile. + +Maven interprets `mirrorOf`: `*` matches every repository, `central` matches +only that repository ID, `external:*` matches external repositories, and +`external:http:*` matches external HTTP repositories. Comma-separated patterns +such as `external:*,!internal` exclude specific IDs. Selector text and mirror +order are retained; Maven applies its own matching and precedence rules. +For example, a public mirror needs no additional repository or credential input: + +```yaml +mvn-mirrors: | + public:https://mirror.example.com:8443/maven:external:*,!internal +``` + +Mirror generation respects `settings-path` and `overwrite-settings`. An existing +settings file is left untouched when `overwrite-settings: false`; this input +does not merge mirrors into that file. + ### GPG The example above uses the [Maven GPG Plugin](https://maven.apache.org/plugins/maven-gpg-plugin/)'s Bouncy Castle signer (`-Dgpg.signer=bc`, available since `maven-gpg-plugin` 3.2.0). It is a pure-Java signer that reads the key directly from the `MAVEN_GPG_KEY` environment variable, so it does **not** require the `gpg` executable, importing the key into a GPG keychain, or the `--pinentry-mode loopback` workaround in your `pom.xml`. The key must be an ASCII-armored secret key (transferable secret key format). diff --git a/src/auth.ts b/src/auth.ts index 91480f76..c3c25112 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -30,11 +30,20 @@ export interface MavenRepositorySettings { prioritizeCentral: boolean; } +export interface MavenMirror { + id: string; + url: string; + mirrorOf: string; +} + const MAVEN_REPOSITORY_ORIGIN = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/?#@]+\/?$/; export async function configureAuthentication() { const servers = getMavenServerSettings(); const repositorySettings = getMavenRepositorySettings(); + const mirrors = parseMavenMirrors( + core.getMultilineInput(constants.INPUT_MVN_MIRRORS) + ); const settingsDirectory = core.getInput(constants.INPUT_SETTINGS_PATH) || path.join(os.homedir(), constants.M2_DIR); @@ -60,7 +69,8 @@ export async function configureAuthentication() { settingsDirectory, overwriteSettings, gpgPassphraseEnvVar, - repositorySettings + repositorySettings, + mirrors ); if (gpgPrivateKey) { @@ -333,6 +343,52 @@ export function parseMavenRepositories( return repositories; } +// only exported for testing purposes +export function parseMavenMirrors(entries: string[]): MavenMirror[] { + const mirrors: MavenMirror[] = []; + const mirrorIds = new Set(); + + entries.forEach((entry, index) => { + if (!entry.trim()) { + return; + } + + const separator = entry.indexOf(':'); + const remainder = entry.slice(separator + 1).trim(); + // Consume the URL scheme before looking for the selector delimiter. Selector + // tokens may themselves contain colons (external:* and external:http:*). + const scheme = /^[A-Za-z][A-Za-z0-9+.-]*:/.exec(remainder)?.[0] ?? ''; + const fields = + /^(.*?):((?:\s*!?external:(?:http:)?\*\s*|[^:,]*)(?:,(?:\s*!?external:(?:http:)?\*\s*|[^:,]*))*)$/.exec( + remainder.slice(scheme.length) + ); + if (separator < 0 || !fields) { + throw new Error( + `Invalid mvn-mirrors entry at line ${index + 1}. Expected format: mirror-id:mirror-url:mirror-of` + ); + } + + const id = entry.slice(0, separator).trim(); + const url = `${scheme}${fields[1]}`.trim(); + const mirrorOf = fields[2].trim(); + if (!id || !url || !mirrorOf) { + throw new Error( + `Invalid mvn-mirrors entry at line ${index + 1}. mirror-id, mirror URL, and mirror-of are required` + ); + } + if (mirrorIds.has(id)) { + throw new Error( + `Duplicate mirror-id '${id}' in mvn-mirrors input at line ${index + 1}` + ); + } + + mirrorIds.add(id); + mirrors.push({id, url, mirrorOf}); + }); + + return mirrors; +} + function generateRepositorySection( customRepositories: MavenRepository[], settings: MavenRepositorySettings, @@ -388,7 +444,8 @@ export async function createAuthenticationSettings( settingsDirectory: string, overwriteSettings: boolean, gpgPassphraseEnvVar: string | undefined = undefined, - repositorySettings: MavenRepositorySettings | undefined = undefined + repositorySettings: MavenRepositorySettings | undefined = undefined, + mirrors: MavenMirror[] = [] ) { core.info( `Creating ${constants.MVN_SETTINGS_FILE} with server-id: ${servers.map(server => server.id).join(', ')}` @@ -398,7 +455,7 @@ export async function createAuthenticationSettings( await io.mkdirP(settingsDirectory); await write( settingsDirectory, - generate(servers, gpgPassphraseEnvVar, repositorySettings), + generate(servers, gpgPassphraseEnvVar, repositorySettings, mirrors), overwriteSettings ); } @@ -407,7 +464,8 @@ export async function createAuthenticationSettings( export function generate( servers: MavenServerCredentials[], gpgPassphraseEnvVar?: string | undefined, - repositorySettings?: MavenRepositorySettings | undefined + repositorySettings?: MavenRepositorySettings | undefined, + mirrors: MavenMirror[] = [] ) { // The maven-gpg-plugin reads the passphrase from the environment variable // named by the `gpg.passphraseEnvName` property (default MAVEN_GPG_PASSPHRASE). @@ -447,6 +505,20 @@ export function generate( } lines.push(' '); + if (mirrors.length) { + lines.push(' '); + for (const mirror of mirrors) { + lines.push( + ' ', + ` ${escapeXmlText(mirror.id)}`, + ` ${escapeXmlText(mirror.url)}`, + ` ${escapeXmlText(mirror.mirrorOf)}`, + ' ' + ); + } + lines.push(' '); + } + if (repositorySettings || includeGpgPassphraseProfile) { lines.push(' '); if (repositorySettings) { diff --git a/src/constants.ts b/src/constants.ts index bdba9224..03942193 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -20,6 +20,7 @@ export const INPUT_MVN_SERVER_REPOSITORY_ORIGINS = 'mvn-server-repository-origins'; export const INPUT_MVN_REPOSITORIES = 'mvn-repositories'; export const INPUT_MVN_PLUGIN_REPOSITORIES = 'mvn-plugin-repositories'; +export const INPUT_MVN_MIRRORS = 'mvn-mirrors'; export const INPUT_MVN_REPOSITORIES_INCLUDE_CENTRAL = 'mvn-repositories-include-central'; export const INPUT_MVN_REPOSITORIES_PRIORITIZE_CENTRAL =