forked from boos/BoosAPI
Carry protocol review fixes into release
The release branch is main-based and tracks the reviewed dev slice for PR publication. This merge carries the post-review compatibility fixes without rewriting the already-open PR history. Constraint: PR #533 publishes dev changes through a clean release branch targeting main. Rejected: Recreate the PR branch from scratch | unnecessary churn after green checks and review context already exist. Confidence: high Scope-risk: narrow Tested: npx vitest run --root . src/server/proxy-core/responsesPreflight.test.ts src/server/proxy-core/serviceTierPolicy.test.ts src/server/routes/proxy/chat.stream.test.ts src/web/pages/settings/UpdateCenterSection.test.tsx Tested: npm run typecheck:server Tested: npm run typecheck:web Tested: npm run repo:drift-check Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { validateExternalResponsesHttpRequest } from './responsesPreflight.js';
|
import {
|
||||||
|
hasResponsesWebSearchOnlyRequest,
|
||||||
|
validateExternalResponsesHttpRequest,
|
||||||
|
} from './responsesPreflight.js';
|
||||||
|
|
||||||
describe('validateExternalResponsesHttpRequest', () => {
|
describe('validateExternalResponsesHttpRequest', () => {
|
||||||
it('rejects external HTTP previous_response_id and explains msg ids separately', () => {
|
it('rejects external HTTP previous_response_id and explains msg ids separately', () => {
|
||||||
@@ -63,3 +66,21 @@ describe('validateExternalResponsesHttpRequest', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('hasResponsesWebSearchOnlyRequest', () => {
|
||||||
|
it('recognizes the current web_search_preview tool alias', () => {
|
||||||
|
expect(hasResponsesWebSearchOnlyRequest({
|
||||||
|
model: 'gpt-5',
|
||||||
|
tools: [{ type: 'web_search_preview_2025_03_11' }],
|
||||||
|
input: 'metapi protocol compatibility',
|
||||||
|
})).toBe(true);
|
||||||
|
|
||||||
|
expect(hasResponsesWebSearchOnlyRequest({
|
||||||
|
model: 'gpt-5',
|
||||||
|
tools: [
|
||||||
|
{ type: 'web_search_preview_2025_03_11' },
|
||||||
|
{ type: 'function', name: 'lookup' },
|
||||||
|
],
|
||||||
|
input: 'metapi protocol compatibility',
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ function isWebSearchToolRecord(tool: Record<string, unknown>): boolean {
|
|||||||
const name = asTrimmedString(tool.name).toLowerCase();
|
const name = asTrimmedString(tool.name).toLowerCase();
|
||||||
return type === 'web_search'
|
return type === 'web_search'
|
||||||
|| type === 'web_search_preview'
|
|| type === 'web_search_preview'
|
||||||
|
|| type === 'web_search_preview_2025_03_11'
|
||||||
|| type === 'web_search_20250305'
|
|| type === 'web_search_20250305'
|
||||||
|| type === 'google_search'
|
|| type === 'google_search'
|
||||||
|| name === 'web_search'
|
|| name === 'web_search'
|
||||||
|
|||||||
@@ -3,7 +3,15 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { applyOpenAiServiceTierPolicy } from './serviceTierPolicy.js';
|
import { applyOpenAiServiceTierPolicy } from './serviceTierPolicy.js';
|
||||||
|
|
||||||
describe('applyOpenAiServiceTierPolicy', () => {
|
describe('applyOpenAiServiceTierPolicy', () => {
|
||||||
it('normalizes fast to priority and drops unknown or non-string tiers', () => {
|
it('normalizes fast to priority, leaves absent tiers alone, and drops unknown or non-string tiers', () => {
|
||||||
|
expect(applyOpenAiServiceTierPolicy({
|
||||||
|
body: { model: 'gpt-5' },
|
||||||
|
})).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
body: { model: 'gpt-5' },
|
||||||
|
action: 'pass',
|
||||||
|
});
|
||||||
|
|
||||||
expect(applyOpenAiServiceTierPolicy({
|
expect(applyOpenAiServiceTierPolicy({
|
||||||
body: { model: 'gpt-5', service_tier: ' fast ' },
|
body: { model: 'gpt-5', service_tier: ' fast ' },
|
||||||
})).toMatchObject({
|
})).toMatchObject({
|
||||||
@@ -73,4 +81,3 @@ describe('applyOpenAiServiceTierPolicy', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export function applyOpenAiServiceTierPolicy(input: {
|
|||||||
context?: ServiceTierRuleContext;
|
context?: ServiceTierRuleContext;
|
||||||
rules?: unknown;
|
rules?: unknown;
|
||||||
}): ServiceTierPolicyResult {
|
}): ServiceTierPolicyResult {
|
||||||
|
const hasRequestedServiceTier = Object.prototype.hasOwnProperty.call(input.body, 'service_tier');
|
||||||
const rawTier = input.body.service_tier;
|
const rawTier = input.body.service_tier;
|
||||||
const serviceTier = normalizeServiceTier(rawTier);
|
const serviceTier = normalizeServiceTier(rawTier);
|
||||||
const next = { ...input.body };
|
const next = { ...input.body };
|
||||||
@@ -135,7 +136,7 @@ export function applyOpenAiServiceTierPolicy(input: {
|
|||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
body: next,
|
body: next,
|
||||||
action: 'filter',
|
action: hasRequestedServiceTier ? 'filter' : 'pass',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,4 +161,3 @@ export function applyOpenAiServiceTierPolicy(input: {
|
|||||||
action: 'pass',
|
action: 'pass',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ function asTrimmedString(value: unknown): string {
|
|||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SEARCH_SIMULATION_MODEL = '__search';
|
||||||
|
|
||||||
function findSearchTool(body: Record<string, unknown>): Record<string, unknown> | null {
|
function findSearchTool(body: Record<string, unknown>): Record<string, unknown> | null {
|
||||||
const tools = Array.isArray(body.tools) ? body.tools : [];
|
const tools = Array.isArray(body.tools) ? body.tools : [];
|
||||||
for (const tool of tools) {
|
for (const tool of tools) {
|
||||||
@@ -23,6 +25,7 @@ function findSearchTool(body: Record<string, unknown>): Record<string, unknown>
|
|||||||
if (
|
if (
|
||||||
type === 'web_search'
|
type === 'web_search'
|
||||||
|| type === 'web_search_preview'
|
|| type === 'web_search_preview'
|
||||||
|
|| type === 'web_search_preview_2025_03_11'
|
||||||
|| type === 'web_search_20250305'
|
|| type === 'web_search_20250305'
|
||||||
|| type === 'google_search'
|
|| type === 'google_search'
|
||||||
|| name === 'web_search'
|
|| name === 'web_search'
|
||||||
@@ -129,6 +132,7 @@ function buildSyntheticResponsesPayload(input: {
|
|||||||
const responseId = `resp_web_search_${randomUUID()}`;
|
const responseId = `resp_web_search_${randomUUID()}`;
|
||||||
const searchCallId = `ws_${randomUUID()}`;
|
const searchCallId = `ws_${randomUUID()}`;
|
||||||
const results = normalizeSearchResults(input.searchPayload);
|
const results = normalizeSearchResults(input.searchPayload);
|
||||||
|
const outputText = results.length > 0 ? JSON.stringify(results) : '[]';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: responseId,
|
id: responseId,
|
||||||
@@ -136,6 +140,7 @@ function buildSyntheticResponsesPayload(input: {
|
|||||||
created_at: createdAt,
|
created_at: createdAt,
|
||||||
model: asTrimmedString(input.body.model) || 'unknown',
|
model: asTrimmedString(input.body.model) || 'unknown',
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
|
output_text: outputText,
|
||||||
output: [
|
output: [
|
||||||
{
|
{
|
||||||
id: searchCallId,
|
id: searchCallId,
|
||||||
@@ -153,9 +158,7 @@ function buildSyntheticResponsesPayload(input: {
|
|||||||
status: 'completed',
|
status: 'completed',
|
||||||
content: [{
|
content: [{
|
||||||
type: 'output_text',
|
type: 'output_text',
|
||||||
text: results.length > 0
|
text: outputText,
|
||||||
? JSON.stringify(results)
|
|
||||||
: '[]',
|
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -193,7 +196,7 @@ async function sendAnthropicSearchSimulation(input: {
|
|||||||
app: input.app,
|
app: input.app,
|
||||||
request: input.request,
|
request: input.request,
|
||||||
query,
|
query,
|
||||||
model: asTrimmedString(input.body.model) || asTrimmedString(input.openAiBody.model) || '__search',
|
model: SEARCH_SIMULATION_MODEL,
|
||||||
maxResults: toSearchMaxResults(tool),
|
maxResults: toSearchMaxResults(tool),
|
||||||
});
|
});
|
||||||
if (search.statusCode < 200 || search.statusCode >= 300) {
|
if (search.statusCode < 200 || search.statusCode >= 300) {
|
||||||
@@ -252,7 +255,7 @@ async function sendResponsesSearchSimulation(input: {
|
|||||||
app: input.app,
|
app: input.app,
|
||||||
request: input.request,
|
request: input.request,
|
||||||
query,
|
query,
|
||||||
model: asTrimmedString(input.body.model) || '__search',
|
model: SEARCH_SIMULATION_MODEL,
|
||||||
maxResults: toSearchMaxResults(tool),
|
maxResults: toSearchMaxResults(tool),
|
||||||
});
|
});
|
||||||
if (search.statusCode < 200 || search.statusCode >= 300) {
|
if (search.statusCode < 200 || search.statusCode >= 300) {
|
||||||
|
|||||||
@@ -4749,6 +4749,7 @@ describe('chat proxy stream behavior', () => {
|
|||||||
|
|
||||||
expect(response.statusCode, response.body).toBe(200);
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(selectChannelMock).toHaveBeenCalledWith('__search', expect.anything());
|
||||||
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
||||||
expect(targetUrl).toBe('https://upstream.example.com/v1/search');
|
expect(targetUrl).toBe('https://upstream.example.com/v1/search');
|
||||||
expect(JSON.parse(options.body)).toMatchObject({
|
expect(JSON.parse(options.body)).toMatchObject({
|
||||||
@@ -4816,6 +4817,7 @@ describe('chat proxy stream behavior', () => {
|
|||||||
|
|
||||||
expect(response.statusCode, response.body).toBe(200);
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(selectChannelMock).toHaveBeenCalledWith('__search', expect.anything());
|
||||||
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
||||||
expect(targetUrl).toBe('https://upstream.example.com/v1/search');
|
expect(targetUrl).toBe('https://upstream.example.com/v1/search');
|
||||||
expect(JSON.parse(options.body)).toMatchObject({
|
expect(JSON.parse(options.body)).toMatchObject({
|
||||||
@@ -4825,6 +4827,7 @@ describe('chat proxy stream behavior', () => {
|
|||||||
|
|
||||||
const body = response.json();
|
const body = response.json();
|
||||||
expect(body.object).toBe('response');
|
expect(body.object).toBe('response');
|
||||||
|
expect(body.output_text).toContain('Metapi Responses');
|
||||||
expect(body.output?.[0]).toMatchObject({
|
expect(body.output?.[0]).toMatchObject({
|
||||||
type: 'web_search_call',
|
type: 'web_search_call',
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
fetchDockerHubTagCandidates,
|
fetchDockerHubTagCandidates,
|
||||||
fetchLatestStableGitHubRelease,
|
fetchLatestStableGitHubRelease,
|
||||||
getCurrentRuntimeVersion,
|
getCurrentRuntimeVersion,
|
||||||
type DockerHubTagCandidates,
|
|
||||||
type UpdateCenterVersionCandidate,
|
type UpdateCenterVersionCandidate,
|
||||||
} from './updateCenterVersionService.js';
|
} from './updateCenterVersionService.js';
|
||||||
import {
|
import {
|
||||||
@@ -164,8 +163,8 @@ export async function buildUpdateCenterStatus(): Promise<UpdateCenterStatusResul
|
|||||||
loadUpdateCenterRuntimeState(),
|
loadUpdateCenterRuntimeState(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const githubRelease = githubLookup.value as UpdateCenterVersionCandidate | null;
|
const githubRelease = githubLookup.value;
|
||||||
const dockerHubCandidates = dockerLookup.value as DockerHubTagCandidates | null;
|
const dockerHubCandidates = dockerLookup.value;
|
||||||
const dockerHubTag = dockerHubCandidates?.primary || null;
|
const dockerHubTag = dockerHubCandidates?.primary || null;
|
||||||
const dockerHubRecentTags = dockerHubCandidates?.recentNonStable || [];
|
const dockerHubRecentTags = dockerHubCandidates?.recentNonStable || [];
|
||||||
const helper = (helperLookup.value as UpdateCenterHelperStatus | null) || buildUnavailableHelperStatus(helperLookup.error);
|
const helper = (helperLookup.value as UpdateCenterHelperStatus | null) || buildUnavailableHelperStatus(helperLookup.error);
|
||||||
|
|||||||
@@ -500,6 +500,110 @@ describe('UpdateCenterSection', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('disables only the recent Docker candidate that already matches the running helper image', async () => {
|
||||||
|
apiMock.getUpdateCenterStatus.mockResolvedValueOnce({
|
||||||
|
currentVersion: '1.2.3',
|
||||||
|
config: {
|
||||||
|
enabled: true,
|
||||||
|
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
|
||||||
|
namespace: 'ai',
|
||||||
|
releaseName: 'metapi',
|
||||||
|
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||||
|
imageRepository: '1467078763/metapi',
|
||||||
|
githubReleasesEnabled: true,
|
||||||
|
dockerHubTagsEnabled: true,
|
||||||
|
defaultDeploySource: 'docker-hub-tag',
|
||||||
|
},
|
||||||
|
githubRelease: {
|
||||||
|
normalizedVersion: '1.3.0',
|
||||||
|
displayVersion: '1.3.0',
|
||||||
|
},
|
||||||
|
dockerHubTag: {
|
||||||
|
normalizedVersion: 'latest',
|
||||||
|
tagName: 'latest',
|
||||||
|
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||||
|
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||||
|
},
|
||||||
|
dockerHubRecentTags: [
|
||||||
|
{
|
||||||
|
normalizedVersion: 'dev',
|
||||||
|
tagName: 'dev',
|
||||||
|
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
||||||
|
publishedAt: '2026-03-30T11:54:35.591877Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
normalizedVersion: 'dev-20260417-next',
|
||||||
|
tagName: 'dev-20260417-next',
|
||||||
|
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||||
|
displayVersion: 'dev-20260417-next @ sha256:bbbbbbbbbbbb',
|
||||||
|
publishedAt: '2026-03-30T10:54:35.591877Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
helper: {
|
||||||
|
ok: true,
|
||||||
|
healthy: true,
|
||||||
|
revision: '17',
|
||||||
|
imageTag: 'dev',
|
||||||
|
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
history: [],
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
lastCheckedAt: '2026-03-30 20:30:00',
|
||||||
|
lastCheckError: null,
|
||||||
|
lastResolvedSource: 'docker-hub-tag',
|
||||||
|
lastResolvedDisplayVersion: 'dev',
|
||||||
|
lastResolvedCandidateKey: 'docker-hub-tag:dev@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
lastNotifiedCandidateKey: null,
|
||||||
|
lastNotifiedAt: null,
|
||||||
|
},
|
||||||
|
runningTask: null,
|
||||||
|
lastFinishedTask: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ToastProvider>
|
||||||
|
<UpdateCenterSection />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const runningCandidateButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node) === '部署 dev'
|
||||||
|
));
|
||||||
|
const nextCandidateButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node) === '部署 dev-20260417-next'
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(runningCandidateButton.props.disabled).toBe(true);
|
||||||
|
expect(runningCandidateButton.props.title).toContain('当前已运行该镜像');
|
||||||
|
expect(nextCandidateButton.props.disabled).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await nextCandidateButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({
|
||||||
|
source: 'docker-hub-tag',
|
||||||
|
targetTag: 'dev-20260417-next',
|
||||||
|
targetDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps rollback history compact and opens the full revision list in a centered modal', async () => {
|
it('keeps rollback history compact and opens the full revision list in a centered modal', async () => {
|
||||||
let root!: ReactTestRenderer;
|
let root!: ReactTestRenderer;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -445,10 +445,6 @@ export default function UpdateCenterSection() {
|
|||||||
const manualDockerTag = String(manualDockerTarget.tag || '').trim();
|
const manualDockerTag = String(manualDockerTarget.tag || '').trim();
|
||||||
const manualDockerDigest = String(manualDockerTarget.digest || '').trim();
|
const manualDockerDigest = String(manualDockerTarget.digest || '').trim();
|
||||||
const recentDockerCandidates = normalizeRecentDockerCandidates(status?.dockerHubRecentTags);
|
const recentDockerCandidates = normalizeRecentDockerCandidates(status?.dockerHubRecentTags);
|
||||||
const canDeployDockerByTag = !deploying
|
|
||||||
&& config.enabled
|
|
||||||
&& config.dockerHubTagsEnabled
|
|
||||||
&& helperHealthy;
|
|
||||||
const canDeployManualDocker = !deploying
|
const canDeployManualDocker = !deploying
|
||||||
&& config.enabled
|
&& config.enabled
|
||||||
&& config.dockerHubTagsEnabled
|
&& config.dockerHubTagsEnabled
|
||||||
@@ -803,6 +799,15 @@ export default function UpdateCenterSection() {
|
|||||||
const candidateTag = String(candidate.tagName || candidate.normalizedVersion || '').trim();
|
const candidateTag = String(candidate.tagName || candidate.normalizedVersion || '').trim();
|
||||||
const candidateDigest = String(candidate.digest || '').trim();
|
const candidateDigest = String(candidate.digest || '').trim();
|
||||||
const candidateLabel = candidate.displayVersion || candidate.normalizedVersion || candidateTag;
|
const candidateLabel = candidate.displayVersion || candidate.normalizedVersion || candidateTag;
|
||||||
|
const candidateDeployState = describeDockerDeployState({
|
||||||
|
enabled: config.enabled && config.dockerHubTagsEnabled,
|
||||||
|
helperHealthy,
|
||||||
|
helperError: status?.helper?.error,
|
||||||
|
currentVersion: status?.currentVersion,
|
||||||
|
helper: status?.helper,
|
||||||
|
candidate,
|
||||||
|
});
|
||||||
|
const canDeployCandidate = !deploying && candidateDeployState.canDeploy;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${candidateTag}:${candidateDigest || 'no-digest'}`}
|
key={`${candidateTag}:${candidateDigest || 'no-digest'}`}
|
||||||
@@ -825,9 +830,10 @@ export default function UpdateCenterSection() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
style={{ border: '1px solid var(--color-border)' }}
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
disabled={!canDeployDockerByTag || !candidateTag}
|
disabled={!canDeployCandidate || !candidateTag}
|
||||||
|
title={candidateDeployState.reason}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!canDeployDockerByTag || !candidateTag) return;
|
if (!canDeployCandidate || !candidateTag) return;
|
||||||
void runDeploy('docker-hub-tag', {
|
void runDeploy('docker-hub-tag', {
|
||||||
tag: candidateTag,
|
tag: candidateTag,
|
||||||
digest: candidateDigest || null,
|
digest: candidateDigest || null,
|
||||||
|
|||||||
Reference in New Issue
Block a user