Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions .github/actions/extract-bom-versions/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Extract BOM Versions Action

Fetches the Spring Cloud release train BOM (`pom.xml`) directly from GitHub
and exports all version properties (entries ending in `.version`) as both
action outputs and environment variables for use in subsequent workflow steps.

Supports both the public OSS BOM (`spring-cloud-release`) and the private
commercial BOM (`spring-cloud-release-commercial`).

## Usage

### OSS (public repo — no token required)

```yaml
- name: Extract versions from Spring Cloud BOM
id: extract-versions
uses: spring-cloud/spring-cloud-github-actions/.github/actions/extract-bom-versions@main
with:
ref: '2023.0.x'

# Access as a JSON output via fromJSON()
- name: Print Spring Boot version
run: |
echo "Spring Boot: ${{ fromJSON(steps.extract-versions.outputs.versions)['spring-boot'] }}"

# Or use the exported environment variables directly in shell steps
- name: Update POM versions
run: |
mvn versions:set -DnewVersion=$RELEASE_TRAIN_VERSION
mvn versions:set-property -Dproperty=spring-boot.version -DnewValue=$SPRING_BOOT_VERSION
```

### Commercial (private repo — token required)

```yaml
- name: Extract versions from commercial Spring Cloud BOM
id: extract-versions
uses: spring-cloud/spring-cloud-github-actions/.github/actions/extract-bom-versions@main
with:
ref: '2023.0.x'
commercial: 'true'
token: ${{ secrets.COMMERCIAL_GITHUB_TOKEN }}
```

## Inputs

| Input | Required | Default | Description |
|-------|----------|---------|-------------|
| `ref` | No | `main` | Branch, tag, or SHA to fetch the BOM from (e.g. `2023.0.x`, `2024.0.x`). |
| `commercial` | No | `false` | When `true`, fetches from `spring-cloud-release-commercial`. A `token` with access to that private repo must be supplied. |
| `token` | No* | `github.token` | GitHub token for fetching the BOM. *Required when `commercial` is `true`. |

## Outputs

| Output | Description |
|--------|-------------|
| `versions` | JSON object of all extracted versions keyed by project name, e.g. `{"spring-boot":"3.2.3","spring-cloud-config":"4.1.1"}` |

## Environment Variables

In addition to the `versions` JSON output, each extracted version is also
exported as an environment variable for convenience in shell steps.

Naming convention: `{PROJECT_NAME}_VERSION` (upper snake case)

| BOM property | Environment variable |
|--------------|----------------------|
| `<version>` (release train) | `RELEASE_TRAIN_VERSION` |
| `<spring-boot.version>` | `SPRING_BOOT_VERSION` |
| `<spring-cloud-config.version>` | `SPRING_CLOUD_CONFIG_VERSION` |
| `<spring-cloud-gateway.version>` | `SPRING_CLOUD_GATEWAY_VERSION` |
| `<spring-cloud-kubernetes.version>` | `SPRING_CLOUD_KUBERNETES_VERSION` |
| *(and so on for every `.version` property in the BOM)* | |

## Development

### Prerequisites

- Node.js 20+
- npm

### Setup

```bash
cd .github/actions/extract-bom-versions
npm install
```

### Running Unit Tests

Unit tests use Jest and run entirely locally — no GitHub Actions context needed:

```bash
npm test

# With coverage report
npm run test:coverage
```

### Building the Dist Bundle

The `dist/index.js` bundle **must be rebuilt and committed** whenever
`src/index.js` is changed. The `dist-up-to-date` CI job will fail on PRs
if you forget.

```bash
npm run build
git add dist/index.js
git commit -m "chore: rebuild dist"
```

### Integration Testing

Push your branch to GitHub and the `Test - Extract BOM Versions` workflow will
run automatically, executing the full integration test against the real
Spring Cloud BOM.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2023.0.0</version>
<packaging>pom</packaging>

<properties>
<spring-boot.version>3.2.0</spring-boot.version>
</properties>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2023.0.0</version>
<packaging>pom</packaging>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2023.0.1</version>
<packaging>pom</packaging>

<properties>
<spring-boot.version>3.2.3</spring-boot.version>
<spring-cloud-build.version>4.1.2</spring-cloud-build.version>
<spring-cloud-config.version>4.1.1</spring-cloud-config.version>
<spring-cloud-bus.version>4.1.1</spring-cloud-bus.version>
<spring-cloud-commons.version>4.1.1</spring-cloud-commons.version>
<spring-cloud-circuitbreaker.version>3.1.1</spring-cloud-circuitbreaker.version>
<spring-cloud-gateway.version>4.1.2</spring-cloud-gateway.version>
<spring-cloud-kubernetes.version>3.1.1</spring-cloud-kubernetes.version>
<spring-cloud-openfeign.version>4.1.1</spring-cloud-openfeign.version>
<spring-cloud-sleuth.version>3.1.9</spring-cloud-sleuth.version>
<spring-cloud-stream.version>4.1.1</spring-cloud-stream.version>
<spring-cloud-vault.version>4.1.1</spring-cloud-vault.version>
<spring-cloud-zookeeper.version>4.1.0</spring-cloud-zookeeper.version>
<!-- These properties should be ignored (do not end in .version) -->
<java.compiler.release>17</java.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

</project>
209 changes: 209 additions & 0 deletions .github/actions/extract-bom-versions/__tests__/index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
const fs = require('fs');
const path = require('path');
const { getBomUrl, fetchBomXml, extractVersions, nameToEnvVar } = require('../src/index');

const OSS_REPO = 'spring-cloud/spring-cloud-release';
const COMMERCIAL_REPO = 'spring-cloud/spring-cloud-release-commercial';
const bomUrl = (repo, ref) =>
`https://raw.githubusercontent.com/${repo}/${ref}/spring-cloud-dependencies/pom.xml`;

const fixturePath = (name) => path.join(__dirname, 'fixtures', name);
const loadFixture = (name) => fs.readFileSync(fixturePath(name), 'utf-8');

// ─── getBomUrl ───────────────────────────────────────────────────────────────

describe('getBomUrl', () => {
it('returns the OSS BOM URL for the given ref', () => {
expect(getBomUrl(false, '2023.0.x')).toBe(bomUrl(OSS_REPO, '2023.0.x'));
});

it('returns the commercial BOM URL for the given ref', () => {
expect(getBomUrl(true, '2023.0.x')).toBe(bomUrl(COMMERCIAL_REPO, '2023.0.x'));
});

it('defaults to main when ref is main', () => {
expect(getBomUrl(false, 'main')).toBe(bomUrl(OSS_REPO, 'main'));
});

it('accepts a tag as the ref', () => {
expect(getBomUrl(false, 'v2023.0.1')).toBe(bomUrl(OSS_REPO, 'v2023.0.1'));
});
});

// ─── fetchBomXml ─────────────────────────────────────────────────────────────

describe('fetchBomXml', () => {
afterEach(() => jest.restoreAllMocks());

it('fetches XML without an Authorization header for public repos', async () => {
const url = bomUrl(OSS_REPO, '2023.0.x');
const mockXml = loadFixture('minimal-bom.xml');
jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
text: async () => mockXml,
});

const xml = await fetchBomXml(url, '');

expect(global.fetch).toHaveBeenCalledWith(url, {
headers: { Accept: 'text/plain' },
});
expect(xml).toBe(mockXml);
});

it('includes an Authorization header when a token is provided', async () => {
const url = bomUrl(COMMERCIAL_REPO, '2023.0.x');
const mockXml = loadFixture('minimal-bom.xml');
jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
text: async () => mockXml,
});

await fetchBomXml(url, 'my-secret-token');

expect(global.fetch).toHaveBeenCalledWith(url, {
headers: {
Accept: 'text/plain',
Authorization: 'Bearer my-secret-token',
},
});
});

it('throws a descriptive error on a non-OK response', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
});

await expect(fetchBomXml(bomUrl(OSS_REPO, 'main'), '')).rejects.toThrow(
'Failed to fetch BOM (HTTP 404: Not Found)'
);
});

it('appends an access hint on 401 Unauthorized', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized',
});

await expect(fetchBomXml(bomUrl(COMMERCIAL_REPO, '2023.0.x'), 'bad-token')).rejects.toThrow(
'ensure the token has read access to the repository'
);
});

it('appends an access hint on 403 Forbidden', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden',
});

await expect(fetchBomXml(bomUrl(COMMERCIAL_REPO, '2023.0.x'), 'bad-token')).rejects.toThrow(
'ensure the token has read access to the repository'
);
});
});

// ─── extractVersions ────────────────────────────────────────────────────────

describe('extractVersions', () => {
describe('with a full Spring Cloud BOM', () => {
let versions;

beforeAll(() => {
versions = extractVersions(loadFixture('sample-bom.xml'));
});

it('extracts the release train version', () => {
expect(versions['release-train']).toBe('2023.0.1');
});

it('extracts spring-boot.version', () => {
expect(versions['spring-boot']).toBe('3.2.3');
});

it('extracts spring-cloud-config.version', () => {
expect(versions['spring-cloud-config']).toBe('4.1.1');
});

it('extracts spring-cloud-gateway.version', () => {
expect(versions['spring-cloud-gateway']).toBe('4.1.2');
});

it('extracts spring-cloud-kubernetes.version', () => {
expect(versions['spring-cloud-kubernetes']).toBe('3.1.1');
});

it('does NOT include properties that do not end in .version', () => {
// java.compiler.release and project.build.sourceEncoding should be ignored
expect(versions['java.compiler.release']).toBeUndefined();
expect(versions['project.build.sourceEncoding']).toBeUndefined();
});

it('includes all expected version entries', () => {
const expectedKeys = [
'release-train',
'spring-boot',
'spring-cloud-build',
'spring-cloud-config',
'spring-cloud-bus',
'spring-cloud-commons',
'spring-cloud-circuitbreaker',
'spring-cloud-gateway',
'spring-cloud-kubernetes',
'spring-cloud-openfeign',
'spring-cloud-sleuth',
'spring-cloud-stream',
'spring-cloud-vault',
'spring-cloud-zookeeper',
];
expect(Object.keys(versions).sort()).toEqual(expectedKeys.sort());
});
});

describe('with a minimal BOM (single property)', () => {
it('extracts the only version property', () => {
const versions = extractVersions(loadFixture('minimal-bom.xml'));
expect(versions['spring-boot']).toBe('3.2.0');
expect(versions['release-train']).toBe('2023.0.0');
});
});

describe('with a BOM that has no <properties>', () => {
it('returns only the release-train version', () => {
const versions = extractVersions(loadFixture('no-properties-bom.xml'));
expect(Object.keys(versions)).toEqual(['release-train']);
expect(versions['release-train']).toBe('2023.0.0');
});
});

describe('with invalid XML', () => {
it('throws an error when there is no <project> root', () => {
expect(() => extractVersions('<notaproject/>')).toThrow(
'Invalid POM file: no <project> root element found'
);
});
});
});

// ─── nameToEnvVar ────────────────────────────────────────────────────────────

describe('nameToEnvVar', () => {
it('converts release-train to RELEASE_TRAIN_VERSION', () => {
expect(nameToEnvVar('release-train')).toBe('RELEASE_TRAIN_VERSION');
});

it('converts spring-boot to SPRING_BOOT_VERSION', () => {
expect(nameToEnvVar('spring-boot')).toBe('SPRING_BOOT_VERSION');
});

it('converts spring-cloud-config to SPRING_CLOUD_CONFIG_VERSION', () => {
expect(nameToEnvVar('spring-cloud-config')).toBe('SPRING_CLOUD_CONFIG_VERSION');
});

it('converts spring-cloud-kubernetes to SPRING_CLOUD_KUBERNETES_VERSION', () => {
expect(nameToEnvVar('spring-cloud-kubernetes')).toBe('SPRING_CLOUD_KUBERNETES_VERSION');
});
});
Loading
Loading