Skip to content

Commit 3a78f8a

Browse files
committed
Initial commit
1 parent 3bdacab commit 3a78f8a

38 files changed

Lines changed: 65078 additions & 0 deletions
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Extract BOM Versions Action
2+
3+
Fetches the Spring Cloud release train BOM (`pom.xml`) directly from GitHub
4+
and exports all version properties (entries ending in `.version`) as both
5+
action outputs and environment variables for use in subsequent workflow steps.
6+
7+
Supports both the public OSS BOM (`spring-cloud-release`) and the private
8+
commercial BOM (`spring-cloud-release-commercial`).
9+
10+
## Usage
11+
12+
### OSS (public repo — no token required)
13+
14+
```yaml
15+
- name: Extract versions from Spring Cloud BOM
16+
id: extract-versions
17+
uses: spring-cloud/spring-cloud-github-actions/.github/actions/extract-bom-versions@main
18+
with:
19+
ref: '2023.0.x'
20+
21+
# Access as a JSON output via fromJSON()
22+
- name: Print Spring Boot version
23+
run: |
24+
echo "Spring Boot: ${{ fromJSON(steps.extract-versions.outputs.versions)['spring-boot'] }}"
25+
26+
# Or use the exported environment variables directly in shell steps
27+
- name: Update POM versions
28+
run: |
29+
mvn versions:set -DnewVersion=$RELEASE_TRAIN_VERSION
30+
mvn versions:set-property -Dproperty=spring-boot.version -DnewValue=$SPRING_BOOT_VERSION
31+
```
32+
33+
### Commercial (private repo — token required)
34+
35+
```yaml
36+
- name: Extract versions from commercial Spring Cloud BOM
37+
id: extract-versions
38+
uses: spring-cloud/spring-cloud-github-actions/.github/actions/extract-bom-versions@main
39+
with:
40+
ref: '2023.0.x'
41+
commercial: 'true'
42+
token: ${{ secrets.COMMERCIAL_GITHUB_TOKEN }}
43+
```
44+
45+
## Inputs
46+
47+
| Input | Required | Default | Description |
48+
|-------|----------|---------|-------------|
49+
| `ref` | No | `main` | Branch, tag, or SHA to fetch the BOM from (e.g. `2023.0.x`, `2024.0.x`). |
50+
| `commercial` | No | `false` | When `true`, fetches from `spring-cloud-release-commercial`. A `token` with access to that private repo must be supplied. |
51+
| `token` | No* | `github.token` | GitHub token for fetching the BOM. *Required when `commercial` is `true`. |
52+
53+
## Outputs
54+
55+
| Output | Description |
56+
|--------|-------------|
57+
| `versions` | JSON object of all extracted versions keyed by project name, e.g. `{"spring-boot":"3.2.3","spring-cloud-config":"4.1.1"}` |
58+
59+
## Environment Variables
60+
61+
In addition to the `versions` JSON output, each extracted version is also
62+
exported as an environment variable for convenience in shell steps.
63+
64+
Naming convention: `{PROJECT_NAME}_VERSION` (upper snake case)
65+
66+
| BOM property | Environment variable |
67+
|--------------|----------------------|
68+
| `<version>` (release train) | `RELEASE_TRAIN_VERSION` |
69+
| `<spring-boot.version>` | `SPRING_BOOT_VERSION` |
70+
| `<spring-cloud-config.version>` | `SPRING_CLOUD_CONFIG_VERSION` |
71+
| `<spring-cloud-gateway.version>` | `SPRING_CLOUD_GATEWAY_VERSION` |
72+
| `<spring-cloud-kubernetes.version>` | `SPRING_CLOUD_KUBERNETES_VERSION` |
73+
| *(and so on for every `.version` property in the BOM)* | |
74+
75+
## Development
76+
77+
### Prerequisites
78+
79+
- Node.js 20+
80+
- npm
81+
82+
### Setup
83+
84+
```bash
85+
cd .github/actions/extract-bom-versions
86+
npm install
87+
```
88+
89+
### Running Unit Tests
90+
91+
Unit tests use Jest and run entirely locally — no GitHub Actions context needed:
92+
93+
```bash
94+
npm test
95+
96+
# With coverage report
97+
npm run test:coverage
98+
```
99+
100+
### Building the Dist Bundle
101+
102+
The `dist/index.js` bundle **must be rebuilt and committed** whenever
103+
`src/index.js` is changed. The `dist-up-to-date` CI job will fail on PRs
104+
if you forget.
105+
106+
```bash
107+
npm run build
108+
git add dist/index.js
109+
git commit -m "chore: rebuild dist"
110+
```
111+
112+
### Integration Testing
113+
114+
Push your branch to GitHub and the `Test - Extract BOM Versions` workflow will
115+
run automatically, executing the full integration test against the real
116+
Spring Cloud BOM.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project>
3+
<groupId>org.springframework.cloud</groupId>
4+
<artifactId>spring-cloud-dependencies</artifactId>
5+
<version>2023.0.0</version>
6+
<packaging>pom</packaging>
7+
8+
<properties>
9+
<spring-boot.version>3.2.0</spring-boot.version>
10+
</properties>
11+
</project>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project>
3+
<groupId>org.springframework.cloud</groupId>
4+
<artifactId>spring-cloud-dependencies</artifactId>
5+
<version>2023.0.0</version>
6+
<packaging>pom</packaging>
7+
</project>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
5+
<modelVersion>4.0.0</modelVersion>
6+
7+
<groupId>org.springframework.cloud</groupId>
8+
<artifactId>spring-cloud-dependencies</artifactId>
9+
<version>2023.0.1</version>
10+
<packaging>pom</packaging>
11+
12+
<properties>
13+
<spring-boot.version>3.2.3</spring-boot.version>
14+
<spring-cloud-build.version>4.1.2</spring-cloud-build.version>
15+
<spring-cloud-config.version>4.1.1</spring-cloud-config.version>
16+
<spring-cloud-bus.version>4.1.1</spring-cloud-bus.version>
17+
<spring-cloud-commons.version>4.1.1</spring-cloud-commons.version>
18+
<spring-cloud-circuitbreaker.version>3.1.1</spring-cloud-circuitbreaker.version>
19+
<spring-cloud-gateway.version>4.1.2</spring-cloud-gateway.version>
20+
<spring-cloud-kubernetes.version>3.1.1</spring-cloud-kubernetes.version>
21+
<spring-cloud-openfeign.version>4.1.1</spring-cloud-openfeign.version>
22+
<spring-cloud-sleuth.version>3.1.9</spring-cloud-sleuth.version>
23+
<spring-cloud-stream.version>4.1.1</spring-cloud-stream.version>
24+
<spring-cloud-vault.version>4.1.1</spring-cloud-vault.version>
25+
<spring-cloud-zookeeper.version>4.1.0</spring-cloud-zookeeper.version>
26+
<!-- These properties should be ignored (do not end in .version) -->
27+
<java.compiler.release>17</java.compiler.release>
28+
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
29+
</properties>
30+
31+
</project>
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
const { getBomUrl, fetchBomXml, extractVersions, nameToEnvVar } = require('../src/index');
4+
5+
const OSS_REPO = 'spring-cloud/spring-cloud-release';
6+
const COMMERCIAL_REPO = 'spring-cloud/spring-cloud-release-commercial';
7+
const bomUrl = (repo, ref) =>
8+
`https://raw.githubusercontent.com/${repo}/${ref}/pom.xml`;
9+
10+
const fixturePath = (name) => path.join(__dirname, 'fixtures', name);
11+
const loadFixture = (name) => fs.readFileSync(fixturePath(name), 'utf-8');
12+
13+
// ─── getBomUrl ───────────────────────────────────────────────────────────────
14+
15+
describe('getBomUrl', () => {
16+
it('returns the OSS BOM URL for the given ref', () => {
17+
expect(getBomUrl(false, '2023.0.x')).toBe(bomUrl(OSS_REPO, '2023.0.x'));
18+
});
19+
20+
it('returns the commercial BOM URL for the given ref', () => {
21+
expect(getBomUrl(true, '2023.0.x')).toBe(bomUrl(COMMERCIAL_REPO, '2023.0.x'));
22+
});
23+
24+
it('defaults to main when ref is main', () => {
25+
expect(getBomUrl(false, 'main')).toBe(bomUrl(OSS_REPO, 'main'));
26+
});
27+
28+
it('accepts a tag as the ref', () => {
29+
expect(getBomUrl(false, 'v2023.0.1')).toBe(bomUrl(OSS_REPO, 'v2023.0.1'));
30+
});
31+
});
32+
33+
// ─── fetchBomXml ─────────────────────────────────────────────────────────────
34+
35+
describe('fetchBomXml', () => {
36+
afterEach(() => jest.restoreAllMocks());
37+
38+
it('fetches XML without an Authorization header for public repos', async () => {
39+
const url = bomUrl(OSS_REPO, '2023.0.x');
40+
const mockXml = loadFixture('minimal-bom.xml');
41+
jest.spyOn(global, 'fetch').mockResolvedValue({
42+
ok: true,
43+
text: async () => mockXml,
44+
});
45+
46+
const xml = await fetchBomXml(url, '');
47+
48+
expect(global.fetch).toHaveBeenCalledWith(url, {
49+
headers: { Accept: 'text/plain' },
50+
});
51+
expect(xml).toBe(mockXml);
52+
});
53+
54+
it('includes an Authorization header when a token is provided', async () => {
55+
const url = bomUrl(COMMERCIAL_REPO, '2023.0.x');
56+
const mockXml = loadFixture('minimal-bom.xml');
57+
jest.spyOn(global, 'fetch').mockResolvedValue({
58+
ok: true,
59+
text: async () => mockXml,
60+
});
61+
62+
await fetchBomXml(url, 'my-secret-token');
63+
64+
expect(global.fetch).toHaveBeenCalledWith(url, {
65+
headers: {
66+
Accept: 'text/plain',
67+
Authorization: 'Bearer my-secret-token',
68+
},
69+
});
70+
});
71+
72+
it('throws a descriptive error on a non-OK response', async () => {
73+
jest.spyOn(global, 'fetch').mockResolvedValue({
74+
ok: false,
75+
status: 404,
76+
statusText: 'Not Found',
77+
});
78+
79+
await expect(fetchBomXml(bomUrl(OSS_REPO, 'main'), '')).rejects.toThrow(
80+
'Failed to fetch BOM (HTTP 404: Not Found)'
81+
);
82+
});
83+
84+
it('appends an access hint on 401 Unauthorized', async () => {
85+
jest.spyOn(global, 'fetch').mockResolvedValue({
86+
ok: false,
87+
status: 401,
88+
statusText: 'Unauthorized',
89+
});
90+
91+
await expect(fetchBomXml(bomUrl(COMMERCIAL_REPO, '2023.0.x'), 'bad-token')).rejects.toThrow(
92+
'ensure the token has read access to the repository'
93+
);
94+
});
95+
96+
it('appends an access hint on 403 Forbidden', async () => {
97+
jest.spyOn(global, 'fetch').mockResolvedValue({
98+
ok: false,
99+
status: 403,
100+
statusText: 'Forbidden',
101+
});
102+
103+
await expect(fetchBomXml(bomUrl(COMMERCIAL_REPO, '2023.0.x'), 'bad-token')).rejects.toThrow(
104+
'ensure the token has read access to the repository'
105+
);
106+
});
107+
});
108+
109+
// ─── extractVersions ────────────────────────────────────────────────────────
110+
111+
describe('extractVersions', () => {
112+
describe('with a full Spring Cloud BOM', () => {
113+
let versions;
114+
115+
beforeAll(() => {
116+
versions = extractVersions(loadFixture('sample-bom.xml'));
117+
});
118+
119+
it('extracts the release train version', () => {
120+
expect(versions['release-train']).toBe('2023.0.1');
121+
});
122+
123+
it('extracts spring-boot.version', () => {
124+
expect(versions['spring-boot']).toBe('3.2.3');
125+
});
126+
127+
it('extracts spring-cloud-config.version', () => {
128+
expect(versions['spring-cloud-config']).toBe('4.1.1');
129+
});
130+
131+
it('extracts spring-cloud-gateway.version', () => {
132+
expect(versions['spring-cloud-gateway']).toBe('4.1.2');
133+
});
134+
135+
it('extracts spring-cloud-kubernetes.version', () => {
136+
expect(versions['spring-cloud-kubernetes']).toBe('3.1.1');
137+
});
138+
139+
it('does NOT include properties that do not end in .version', () => {
140+
// java.compiler.release and project.build.sourceEncoding should be ignored
141+
expect(versions['java.compiler.release']).toBeUndefined();
142+
expect(versions['project.build.sourceEncoding']).toBeUndefined();
143+
});
144+
145+
it('includes all expected version entries', () => {
146+
const expectedKeys = [
147+
'release-train',
148+
'spring-boot',
149+
'spring-cloud-build',
150+
'spring-cloud-config',
151+
'spring-cloud-bus',
152+
'spring-cloud-commons',
153+
'spring-cloud-circuitbreaker',
154+
'spring-cloud-gateway',
155+
'spring-cloud-kubernetes',
156+
'spring-cloud-openfeign',
157+
'spring-cloud-sleuth',
158+
'spring-cloud-stream',
159+
'spring-cloud-vault',
160+
'spring-cloud-zookeeper',
161+
];
162+
expect(Object.keys(versions).sort()).toEqual(expectedKeys.sort());
163+
});
164+
});
165+
166+
describe('with a minimal BOM (single property)', () => {
167+
it('extracts the only version property', () => {
168+
const versions = extractVersions(loadFixture('minimal-bom.xml'));
169+
expect(versions['spring-boot']).toBe('3.2.0');
170+
expect(versions['release-train']).toBe('2023.0.0');
171+
});
172+
});
173+
174+
describe('with a BOM that has no <properties>', () => {
175+
it('returns only the release-train version', () => {
176+
const versions = extractVersions(loadFixture('no-properties-bom.xml'));
177+
expect(Object.keys(versions)).toEqual(['release-train']);
178+
expect(versions['release-train']).toBe('2023.0.0');
179+
});
180+
});
181+
182+
describe('with invalid XML', () => {
183+
it('throws an error when there is no <project> root', () => {
184+
expect(() => extractVersions('<notaproject/>')).toThrow(
185+
'Invalid POM file: no <project> root element found'
186+
);
187+
});
188+
});
189+
});
190+
191+
// ─── nameToEnvVar ────────────────────────────────────────────────────────────
192+
193+
describe('nameToEnvVar', () => {
194+
it('converts release-train to RELEASE_TRAIN_VERSION', () => {
195+
expect(nameToEnvVar('release-train')).toBe('RELEASE_TRAIN_VERSION');
196+
});
197+
198+
it('converts spring-boot to SPRING_BOOT_VERSION', () => {
199+
expect(nameToEnvVar('spring-boot')).toBe('SPRING_BOOT_VERSION');
200+
});
201+
202+
it('converts spring-cloud-config to SPRING_CLOUD_CONFIG_VERSION', () => {
203+
expect(nameToEnvVar('spring-cloud-config')).toBe('SPRING_CLOUD_CONFIG_VERSION');
204+
});
205+
206+
it('converts spring-cloud-kubernetes to SPRING_CLOUD_KUBERNETES_VERSION', () => {
207+
expect(nameToEnvVar('spring-cloud-kubernetes')).toBe('SPRING_CLOUD_KUBERNETES_VERSION');
208+
});
209+
});

0 commit comments

Comments
 (0)