Skip to content

Commit 0d09fa3

Browse files
morri-sonmorrison-sappiotrjanik
authored
chore: add reusable workflow for publishing OCM component versions (open-component-model#1901)
On-behalf-of: Gerald Morrison (SAP) <gerald.morrison@sap.com> <!-- markdownlint-disable MD041 --> #### What this PR does / why we need it Create a reusable workflow that can build component versions based on a component constructor that can be used in cli/controller build and cli/controller promotion step. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Chores** * Added a CI workflow to publish OCM component versions: verifies artifacts, handles GHCR auth, supports configurable repository/CLI image and optional conflict policy, runs the publish step, and appends a Markdown summary with a link to the published package. * **Tests** * Added unit tests covering constructor parsing, name/version extraction, URL construction, and constructor patching logic. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Gerald Morrison (SAP) <gerald.morrison@sap.com> Co-authored-by: Gerald Morrison (SAP) <gerald.morrison@sap.com> Co-authored-by: Piotr Janik <piotr.janik@sap.com>
1 parent ed40e61 commit 0d09fa3

File tree

3 files changed

+536
-0
lines changed

3 files changed

+536
-0
lines changed
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// @ts-check
2+
import fs from "fs";
3+
import yaml from "js-yaml";
4+
5+
/**
6+
* Parse a component-constructor.yaml file and return the parsed object.
7+
*
8+
* @param {string} filePath - Absolute path to the constructor YAML file
9+
* @returns {Object} Parsed constructor object
10+
* @throws {Error} If file cannot be read or parsed
11+
*/
12+
export function parseConstructorFile(filePath) {
13+
const content = fs.readFileSync(filePath, "utf8");
14+
const doc = yaml.load(content);
15+
if (!doc || typeof doc !== "object") {
16+
throw new Error(`Invalid constructor file: ${filePath}`);
17+
}
18+
return doc;
19+
}
20+
21+
/**
22+
* Extract component name and version from a parsed constructor object.
23+
*
24+
* @param {Object} constructor - Parsed constructor YAML object
25+
* @returns {{ name: string, version: string }}
26+
* @throws {Error} If name or version is missing
27+
*/
28+
export function extractNameVersion(constructor) {
29+
const name = constructor.name;
30+
const version = constructor.version;
31+
32+
if (!name || typeof name !== "string") {
33+
throw new Error("Constructor is missing required field 'name'");
34+
}
35+
if (!version || typeof version !== "string") {
36+
throw new Error("Constructor is missing required field 'version'");
37+
}
38+
39+
return { name, version };
40+
}
41+
42+
/**
43+
* Build a GitHub Packages URL for a component descriptor.
44+
*
45+
* @param {string} repository - GitHub repository (e.g. "open-component-model/open-component-model")
46+
* @param {string} componentName - OCM component name (e.g. "ocm.software/cli")
47+
* @returns {string} Full URL to the package on GitHub
48+
*/
49+
export function buildPackageUrl(repository, componentName) {
50+
// URL-encode the component-descriptors path prefix and slashes in the component name
51+
const encodedName = componentName.replace(/\//g, "%2F");
52+
return `https://github.com/${repository}/pkgs/container/component-descriptors%2F${encodedName}`;
53+
}
54+
55+
/**
56+
* Patch a CLI component constructor for publishing:
57+
* - Rewrite file-based CLI resource input paths to use a relative resources/bin/ prefix
58+
* - Replace the local OCI image resource with an ociArtifact access reference
59+
*
60+
* @param {Object} constructor - Parsed constructor YAML object (mutated in place)
61+
* @param {string} imageRef - Full OCI image reference (e.g. "ghcr.io/owner/cli:tag")
62+
* @param {string} imageTag - Image tag / version string
63+
* @returns {Object} The mutated constructor object
64+
* @throws {Error} If expected resources are not found
65+
*/
66+
export function patchCliConstructor(constructor, imageRef, imageTag) {
67+
if (!Array.isArray(constructor.resources)) {
68+
throw new Error("Constructor has no 'resources' array");
69+
}
70+
71+
let foundImage = false;
72+
73+
for (const resource of constructor.resources) {
74+
// Rewrite CLI binary paths: keep only the filename under resources/bin/
75+
if (
76+
resource.name === "cli" &&
77+
resource.input?.type === "file"
78+
) {
79+
if (typeof resource.input.path !== "string" || resource.input.path.length === 0) {
80+
throw new Error("CLI file resource is missing required field 'input.path'");
81+
}
82+
const parts = resource.input.path.split("/");
83+
const filename = parts[parts.length - 1];
84+
resource.input.path = `resources/bin/${filename}`;
85+
}
86+
87+
// Convert local image resource to ociArtifact access
88+
if (resource.name === "image") {
89+
foundImage = true;
90+
resource.type = "ociImage";
91+
resource.version = imageTag;
92+
resource.access = {
93+
type: "ociArtifact",
94+
imageReference: imageRef,
95+
};
96+
// Remove local-only fields
97+
delete resource.relation;
98+
delete resource.input;
99+
}
100+
}
101+
102+
if (!foundImage) {
103+
throw new Error("Constructor has no resource named 'image'");
104+
}
105+
106+
return constructor;
107+
}
108+
109+
/**
110+
* GitHub Actions entrypoint: summarize a published component version in the step summary.
111+
*
112+
* Environment variables:
113+
* - CONSTRUCTOR_FILE: Path to the component-constructor.yaml (required)
114+
* - OCM_REPOSITORY: Target OCM repository root (required)
115+
* - GITHUB_REPOSITORY: GitHub repository slug (required)
116+
*
117+
* @param {import('@actions/github-script').AsyncFunctionArguments} args
118+
*/
119+
export async function summarizeComponentVersion({ core }) {
120+
const constructorFile = process.env.CONSTRUCTOR_FILE;
121+
const ocmRepository = process.env.OCM_REPOSITORY;
122+
const githubRepository = process.env.GITHUB_REPOSITORY;
123+
124+
if (!constructorFile) {
125+
core.setFailed("CONSTRUCTOR_FILE environment variable is required");
126+
return;
127+
}
128+
if (!ocmRepository) {
129+
core.setFailed("OCM_REPOSITORY environment variable is required");
130+
return;
131+
}
132+
if (!githubRepository) {
133+
core.setFailed("GITHUB_REPOSITORY environment variable is required");
134+
return;
135+
}
136+
137+
try {
138+
const constructor = parseConstructorFile(constructorFile);
139+
const { name, version } = extractNameVersion(constructor);
140+
const componentRef = `${ocmRepository}//${name}:${version}`;
141+
const packageUrl = buildPackageUrl(githubRepository, name);
142+
143+
core.info(`📦 Published component: ${componentRef}`);
144+
145+
await core.summary
146+
.addHeading("Published OCM Component Version")
147+
.addTable([
148+
[
149+
{ data: "Field", header: true },
150+
{ data: "Value", header: true },
151+
],
152+
["Component", name],
153+
["Version", version],
154+
["Reference", `<a href="${packageUrl}">${componentRef}</a>`],
155+
])
156+
.write();
157+
} catch (error) {
158+
core.setFailed(error.message);
159+
}
160+
}
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import assert from "assert";
2+
import fs from "fs";
3+
import os from "os";
4+
import path from "path";
5+
import yaml from "js-yaml";
6+
import {
7+
extractNameVersion,
8+
buildPackageUrl,
9+
patchCliConstructor,
10+
parseConstructorFile,
11+
} from "./component-constructor.js";
12+
13+
// ----------------------------------------------------------
14+
// extractNameVersion
15+
// ----------------------------------------------------------
16+
console.log("Testing extractNameVersion...");
17+
18+
assert.deepStrictEqual(
19+
extractNameVersion({ name: "ocm.software/cli", version: "1.2.3" }),
20+
{ name: "ocm.software/cli", version: "1.2.3" },
21+
"Should extract name and version from valid constructor"
22+
);
23+
24+
assert.deepStrictEqual(
25+
extractNameVersion({ name: "ocm.software/plugins/helm", version: "0.0.0-main" }),
26+
{ name: "ocm.software/plugins/helm", version: "0.0.0-main" },
27+
"Should handle nested component names"
28+
);
29+
30+
assert.throws(
31+
() => extractNameVersion({ version: "1.0.0" }),
32+
/missing required field 'name'/,
33+
"Should throw when name is missing"
34+
);
35+
36+
assert.throws(
37+
() => extractNameVersion({ name: "foo" }),
38+
/missing required field 'version'/,
39+
"Should throw when version is missing"
40+
);
41+
42+
assert.throws(
43+
() => extractNameVersion({ name: "", version: "1.0.0" }),
44+
/missing required field 'name'/,
45+
"Should throw when name is empty"
46+
);
47+
48+
assert.throws(
49+
() => extractNameVersion({ name: 42, version: "1.0.0" }),
50+
/missing required field 'name'/,
51+
"Should throw when name is not a string"
52+
);
53+
54+
// ----------------------------------------------------------
55+
// buildPackageUrl
56+
// ----------------------------------------------------------
57+
console.log("Testing buildPackageUrl...");
58+
59+
assert.strictEqual(
60+
buildPackageUrl("open-component-model/open-component-model", "ocm.software/cli"),
61+
"https://github.com/open-component-model/open-component-model/pkgs/container/component-descriptors%2Focm.software%2Fcli",
62+
"Should build correct package URL with encoded slashes"
63+
);
64+
65+
assert.strictEqual(
66+
buildPackageUrl("my-org/my-repo", "simple"),
67+
"https://github.com/my-org/my-repo/pkgs/container/component-descriptors%2Fsimple",
68+
"Should handle component name without slashes"
69+
);
70+
71+
assert.strictEqual(
72+
buildPackageUrl("org/repo", "a/b/c/d"),
73+
"https://github.com/org/repo/pkgs/container/component-descriptors%2Fa%2Fb%2Fc%2Fd",
74+
"Should encode all slashes in deeply nested component names"
75+
);
76+
77+
// ----------------------------------------------------------
78+
// patchCliConstructor
79+
// ----------------------------------------------------------
80+
console.log("Testing patchCliConstructor...");
81+
82+
{
83+
const constructor = {
84+
name: "ocm.software/cli",
85+
version: "1.0.0",
86+
resources: [
87+
{
88+
name: "cli",
89+
type: "executable",
90+
input: { type: "file", path: "/full/absolute/path/to/bin/ocm-linux-amd64" },
91+
extraIdentity: { os: "linux", architecture: "amd64" },
92+
relation: "local",
93+
},
94+
{
95+
name: "cli",
96+
type: "executable",
97+
input: { type: "file", path: "/another/path/bin/ocm-darwin-arm64" },
98+
extraIdentity: { os: "darwin", architecture: "arm64" },
99+
relation: "local",
100+
},
101+
{
102+
name: "image",
103+
type: "ociImage",
104+
version: "old",
105+
relation: "local",
106+
input: { type: "file", mediaType: "application/vnd.ocm.software.oci.layout.v1+tar", path: "/path/to/cli.tar" },
107+
},
108+
],
109+
};
110+
111+
const result = patchCliConstructor(constructor, "ghcr.io/ocm/cli:v1.0.0", "v1.0.0");
112+
113+
// CLI binary paths should be rewritten
114+
assert.strictEqual(
115+
result.resources[0].input.path,
116+
"resources/bin/ocm-linux-amd64",
117+
"Should rewrite first CLI binary path"
118+
);
119+
assert.strictEqual(
120+
result.resources[1].input.path,
121+
"resources/bin/ocm-darwin-arm64",
122+
"Should rewrite second CLI binary path"
123+
);
124+
125+
// Image resource should be converted to ociArtifact
126+
const image = result.resources[2];
127+
assert.strictEqual(image.type, "ociImage", "Image type should be ociImage");
128+
assert.strictEqual(image.version, "v1.0.0", "Image version should be updated");
129+
assert.deepStrictEqual(image.access, {
130+
type: "ociArtifact",
131+
imageReference: "ghcr.io/ocm/cli:v1.0.0",
132+
}, "Image access should have ociArtifact reference");
133+
assert.strictEqual(image.relation, undefined, "relation should be deleted");
134+
assert.strictEqual(image.input, undefined, "input should be deleted");
135+
}
136+
137+
// patchCliConstructor: missing image resource
138+
assert.throws(
139+
() => patchCliConstructor({ resources: [{ name: "cli", input: { type: "file", path: "x" } }] }, "ref", "tag"),
140+
/no resource named 'image'/,
141+
"Should throw when image resource is missing"
142+
);
143+
144+
// patchCliConstructor: missing resources array
145+
assert.throws(
146+
() => patchCliConstructor({ name: "test" }, "ref", "tag"),
147+
/no 'resources' array/,
148+
"Should throw when resources array is missing"
149+
);
150+
151+
// patchCliConstructor: missing input.path on CLI file resource
152+
assert.throws(
153+
() => patchCliConstructor({
154+
resources: [
155+
{ name: "cli", input: { type: "file" } },
156+
{ name: "image", type: "ociImage", relation: "local", input: { type: "file", path: "x" } },
157+
],
158+
}, "ref", "tag"),
159+
/missing required field 'input\.path'/,
160+
"Should throw when CLI file resource has no input.path"
161+
);
162+
163+
// patchCliConstructor: empty input.path on CLI file resource
164+
assert.throws(
165+
() => patchCliConstructor({
166+
resources: [
167+
{ name: "cli", input: { type: "file", path: "" } },
168+
{ name: "image", type: "ociImage", relation: "local", input: { type: "file", path: "x" } },
169+
],
170+
}, "ref", "tag"),
171+
/missing required field 'input\.path'/,
172+
"Should throw when CLI file resource has empty input.path"
173+
);
174+
175+
// patchCliConstructor: non-file CLI resources are left untouched
176+
{
177+
const constructor = {
178+
resources: [
179+
{ name: "cli", type: "executable", input: { type: "dir", path: "/some/dir" } },
180+
{ name: "image", type: "ociImage", relation: "local", input: { type: "file", path: "x" } },
181+
],
182+
};
183+
patchCliConstructor(constructor, "ref", "tag");
184+
assert.strictEqual(
185+
constructor.resources[0].input.path,
186+
"/some/dir",
187+
"Non-file CLI resources should not be modified"
188+
);
189+
}
190+
191+
// ----------------------------------------------------------
192+
// parseConstructorFile (round-trip via temp file)
193+
// ----------------------------------------------------------
194+
console.log("Testing parseConstructorFile...");
195+
196+
{
197+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ocm-test-"));
198+
const tmpFile = path.join(tmpDir, "component-constructor.yaml");
199+
200+
const testDoc = { name: "ocm.software/test", version: "0.1.0", provider: { name: "test" } };
201+
fs.writeFileSync(tmpFile, yaml.dump(testDoc), "utf8");
202+
203+
const parsed = parseConstructorFile(tmpFile);
204+
assert.strictEqual(parsed.name, "ocm.software/test", "Should parse name from YAML file");
205+
assert.strictEqual(parsed.version, "0.1.0", "Should parse version from YAML file");
206+
207+
// Cleanup
208+
fs.unlinkSync(tmpFile);
209+
fs.rmdirSync(tmpDir);
210+
}
211+
212+
// parseConstructorFile: non-existent file
213+
assert.throws(
214+
() => parseConstructorFile("/nonexistent/file.yaml"),
215+
/ENOENT/,
216+
"Should throw for non-existent file"
217+
);
218+
219+
// parseConstructorFile: invalid YAML (empty file)
220+
{
221+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ocm-test-"));
222+
const tmpFile = path.join(tmpDir, "empty.yaml");
223+
fs.writeFileSync(tmpFile, "", "utf8");
224+
225+
assert.throws(
226+
() => parseConstructorFile(tmpFile),
227+
/Invalid constructor file/,
228+
"Should throw for empty YAML file"
229+
);
230+
231+
fs.unlinkSync(tmpFile);
232+
fs.rmdirSync(tmpDir);
233+
}
234+
235+
console.log("✅ All component-constructor tests passed.");

0 commit comments

Comments
 (0)