Skip to content

Commit 34066e3

Browse files
committed
Resolve upstream version format by checking Docker registry tags
1 parent 758490f commit 34066e3

4 files changed

Lines changed: 191 additions & 15 deletions

File tree

src/commands/githubActions/bumpUpstream/github/fetchGithubUpstreamVersion.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,20 @@ async function fetchGithubLatestTag(repo: string): Promise<string | null> {
4444

4545
// Sort by semver descending to get the highest version
4646
validReleases.sort((a, b) => {
47-
const versionA = semver.coerce(a.tag_name);
48-
const versionB = semver.coerce(b.tag_name);
47+
const versionA = stripTagPrefix(a.tag_name);
48+
const versionB = stripTagPrefix(b.tag_name);
4949
if (!versionA || !versionB) return 0;
5050
return semver.rcompare(versionA, versionB);
5151
});
5252

5353
return validReleases[0].tag_name;
5454
}
55+
56+
/**
57+
* Strips any prefix from a tag name to extract a clean semver version.
58+
* e.g. "v1.2.3" -> "1.2.3", "n8n@2.10.3" -> "2.10.3"
59+
*/
60+
function stripTagPrefix(tag: string): string | null {
61+
const match = tag.match(/(\d+\.\d+\.\d+.*)$/);
62+
return match ? match[1] : null;
63+
}

src/commands/githubActions/bumpUpstream/github/isValidRelease.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ export function isValidRelease(version: string): boolean {
44
// Nightly builds are not considered valid releases (not taken into account by semver)
55
if (version.includes("nightly")) return false;
66

7-
if (semver.valid(version)) {
8-
const preReleases = semver.prerelease(version);
7+
// Strip any prefix (e.g. "v1.2.3" -> "1.2.3", "n8n@2.10.3" -> "2.10.3")
8+
const cleaned = stripTagPrefix(version) || version;
9+
10+
if (semver.valid(cleaned)) {
11+
const preReleases = semver.prerelease(cleaned);
912

1013
// A version is considered a valid release if it has no pre-release components.
1114
return preReleases === null || preReleases.length === 0;
@@ -17,3 +20,8 @@ export function isValidRelease(version: string): boolean {
1720

1821
return true;
1922
}
23+
24+
function stripTagPrefix(tag: string): string | null {
25+
const match = tag.match(/(\d+\.\d+\.\d+.*)$/);
26+
return match ? match[1] : null;
27+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import fs from "fs";
2+
import path from "path";
3+
import { Compose } from "@dappnode/types";
4+
5+
/**
6+
* Given a GitHub release tag (e.g. "v1.17.0", "n8n@2.10.3"), resolves the
7+
* correct version format by checking the upstream Docker image registry.
8+
*
9+
* 1. Finds which compose service uses the given build arg
10+
* 2. Parses the Dockerfile to extract the Docker image that uses that arg
11+
* 3. Checks the Docker registry for tag existence (with/without prefix)
12+
* 4. Returns the version in the format that matches the Docker registry
13+
*/
14+
export async function resolveVersionFormat({
15+
tag,
16+
arg,
17+
compose,
18+
dir
19+
}: {
20+
tag: string;
21+
arg: string;
22+
compose: Compose;
23+
dir: string;
24+
}): Promise<string> {
25+
const stripped = stripTagPrefix(tag);
26+
if (!stripped || stripped === tag) return tag; // No prefix to strip
27+
28+
try {
29+
const dockerImage = getDockerImageForArg(compose, arg, dir);
30+
if (!dockerImage) return tag;
31+
32+
const tagExists = await checkDockerTagExists(dockerImage, stripped);
33+
if (tagExists) return stripped;
34+
35+
return tag;
36+
} catch (e) {
37+
console.warn(`Could not resolve version format for ${tag}, using as-is:`, e.message);
38+
return tag;
39+
}
40+
}
41+
42+
/**
43+
* Finds the Docker image that uses a given build arg by parsing the Dockerfile.
44+
*/
45+
function getDockerImageForArg(
46+
compose: Compose,
47+
arg: string,
48+
dir: string
49+
): string | null {
50+
for (const [, service] of Object.entries(compose.services)) {
51+
if (
52+
typeof service.build !== "string" &&
53+
service.build?.args &&
54+
arg in service.build.args
55+
) {
56+
const buildContext = service.build.context || ".";
57+
const dockerfileName = service.build.dockerfile || "Dockerfile";
58+
const dockerfilePath = path.resolve(dir, buildContext, dockerfileName);
59+
60+
if (!fs.existsSync(dockerfilePath)) continue;
61+
62+
const content = fs.readFileSync(dockerfilePath, "utf-8");
63+
return extractImageForArg(content, arg);
64+
}
65+
}
66+
return null;
67+
}
68+
69+
/**
70+
* Parses a Dockerfile to find the FROM line that references the given ARG,
71+
* and extracts the Docker image name (without the tag).
72+
*
73+
* Handles patterns like:
74+
* FROM ethereum/client-go:${UPSTREAM_VERSION}
75+
* FROM ethereum/client-go:v${UPSTREAM_VERSION}
76+
* FROM ollama/ollama:${OLLAMA_VERSION#v}
77+
* FROM statusim/nimbus-eth2:multiarch-${UPSTREAM_VERSION}
78+
*/
79+
function extractImageForArg(
80+
dockerfileContent: string,
81+
arg: string
82+
): string | null {
83+
const lines = dockerfileContent.split("\n");
84+
85+
for (const line of lines) {
86+
const trimmed = line.trim();
87+
if (!trimmed.startsWith("FROM") || !trimmed.includes(arg)) continue;
88+
89+
// Match: FROM image:tag_pattern (with optional "AS stage")
90+
const match = trimmed.match(/^FROM\s+([^:\s]+)/i);
91+
if (match) return match[1];
92+
}
93+
94+
return null;
95+
}
96+
97+
/**
98+
* Checks if a tag exists on a Docker registry using the Docker Hub v2 API.
99+
* Supports Docker Hub, ghcr.io, and gcr.io.
100+
*/
101+
async function checkDockerTagExists(
102+
image: string,
103+
tag: string
104+
): Promise<boolean> {
105+
const url = getRegistryTagUrl(image, tag);
106+
if (!url) return false;
107+
108+
try {
109+
const response = await fetch(url);
110+
return response.ok;
111+
} catch {
112+
return false;
113+
}
114+
}
115+
116+
function getRegistryTagUrl(image: string, tag: string): string | null {
117+
// ghcr.io/org/image -> GitHub Container Registry
118+
if (image.startsWith("ghcr.io/")) {
119+
const imagePath = image.replace("ghcr.io/", "");
120+
return `https://ghcr.io/v2/${imagePath}/manifests/${tag}`;
121+
}
122+
123+
// gcr.io/project/image -> Google Container Registry
124+
if (image.startsWith("gcr.io/")) {
125+
const imagePath = image.replace("gcr.io/", "");
126+
return `https://gcr.io/v2/${imagePath}/manifests/${tag}`;
127+
}
128+
129+
// Docker Hub: library/image or org/image
130+
const dockerImage = image.includes("/") ? image : `library/${image}`;
131+
return `https://registry.hub.docker.com/v2/repositories/${dockerImage}/tags/${tag}`;
132+
}
133+
134+
function stripTagPrefix(tag: string): string | null {
135+
const match = tag.match(/(\d+\.\d+\.\d+.*)$/);
136+
return match ? match[1] : null;
137+
}

src/commands/githubActions/bumpUpstream/settings/getInitialSettings.ts

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { Manifest, UpstreamItem } from "@dappnode/types";
1+
import { Manifest, UpstreamItem, Compose } from "@dappnode/types";
22
import { readManifest, readCompose } from "../../../../files/index.js";
33
import { arrIsUnique } from "../../../../utils/array.js";
44
import { getFirstAvailableEthProvider } from "../../../../utils/tryEthProviders.js";
55
import { InitialSetupData, GitSettings, UpstreamSettings } from "../types.js";
66
import { fetchGithubUpstreamVersion } from "../github/fetchGithubUpstreamVersion.js";
7+
import { resolveVersionFormat } from "../github/resolveVersionFormat.js";
78

89
export async function getInitialSettings({
910
dir,
@@ -17,7 +18,7 @@ export async function getInitialSettings({
1718
const { manifest, format } = readManifest([{ dir }]);
1819
const compose = readCompose([{ dir }]);
1920

20-
const upstreamSettings = await parseUpstreamSettings(manifest);
21+
const upstreamSettings = await parseUpstreamSettings(manifest, compose, dir);
2122

2223
const gitSettings = getGitSettings();
2324

@@ -44,11 +45,13 @@ export async function getInitialSettings({
4445
* field (array of objects with 'repo', 'arg' and 'version' fields)
4546
*/
4647
async function parseUpstreamSettings(
47-
manifest: Manifest
48+
manifest: Manifest,
49+
compose: Compose,
50+
dir: string
4851
): Promise<UpstreamSettings[] | null> {
4952
const upstreamSettings = manifest.upstream
50-
? await parseUpstreamSettingsNewFormat(manifest.upstream)
51-
: await parseUpstreamSettingsLegacyFormat(manifest);
53+
? await parseUpstreamSettingsNewFormat(manifest.upstream, compose, dir)
54+
: await parseUpstreamSettingsLegacyFormat(manifest, compose, dir);
5255

5356
if (!upstreamSettings || upstreamSettings.length < 1) return null;
5457

@@ -58,13 +61,22 @@ async function parseUpstreamSettings(
5861
}
5962

6063
async function parseUpstreamSettingsNewFormat(
61-
upstream: UpstreamItem[]
64+
upstream: UpstreamItem[],
65+
compose: Compose,
66+
dir: string
6267
): Promise<UpstreamSettings[]> {
6368
const upstreamPromises = upstream.map(async ({ repo, arg, version }) => {
6469
const githubVersion = await fetchGithubUpstreamVersion(repo);
6570

66-
if (githubVersion)
67-
return { repo, arg, manifestVersion: version, githubVersion };
71+
if (githubVersion) {
72+
const resolvedVersion = await resolveVersionFormat({
73+
tag: githubVersion,
74+
arg,
75+
compose,
76+
dir
77+
});
78+
return { repo, arg, manifestVersion: version, githubVersion: resolvedVersion };
79+
}
6880
});
6981

7082
const upstreamResults = await Promise.all(upstreamPromises);
@@ -78,7 +90,9 @@ async function parseUpstreamSettingsNewFormat(
7890
* Currently, 'upstream' field is used instead, which is an array of objects with 'repo', 'arg' and 'version' fields
7991
*/
8092
async function parseUpstreamSettingsLegacyFormat(
81-
manifest: Manifest
93+
manifest: Manifest,
94+
compose: Compose,
95+
dir: string
8296
): Promise<UpstreamSettings[] | null> {
8397
// 'upstreamRepo' and 'upstreamArg' being defined as arrays has been deprecated
8498

@@ -89,12 +103,20 @@ async function parseUpstreamSettingsLegacyFormat(
89103

90104
if (!githubVersion) return null;
91105

106+
const arg = manifest.upstreamArg || "UPSTREAM_VERSION";
107+
const resolvedVersion = await resolveVersionFormat({
108+
tag: githubVersion,
109+
arg,
110+
compose,
111+
dir
112+
});
113+
92114
return [
93115
{
94116
repo: manifest.upstreamRepo,
95117
manifestVersion: manifest.upstreamVersion || "UPSTREAM_VERSION",
96-
arg: manifest.upstreamArg || "UPSTREAM_VERSION",
97-
githubVersion
118+
arg,
119+
githubVersion: resolvedVersion
98120
}
99121
];
100122
}

0 commit comments

Comments
 (0)