scaffolder-backend: split builtin actions into multiple modules

Signed-off-by: Johan Haals <johan.haals@gmail.com>
This commit is contained in:
Patrik Oldsberg
2021-02-24 20:44:37 +01:00
committed by Johan Haals
parent e0c8c6c164
commit 7e8f2552f6
11 changed files with 513 additions and 367 deletions
@@ -1,361 +0,0 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import { resolve as resolvePath, isAbsolute } from 'path';
import Docker from 'dockerode';
import { TemplaterBuilder, TemplaterValues } from '../stages/templater';
import { TemplateAction } from './types';
import { InputError, UrlReader } from '@backstage/backend-common';
import {
GithubCredentialsProvider,
ScmIntegrations,
} from '@backstage/integration';
import { JsonValue } from '@backstage/config';
import { CatalogApi } from '@backstage/catalog-client';
import { getEntityName } from '@backstage/catalog-model';
import { Octokit } from '@octokit/rest';
import { initRepoAndPush } from '../stages/publish/helpers';
async function fetchContents({
urlReader,
integrations,
baseUrl,
fetchUrl = '.',
outputPath,
}: {
urlReader: UrlReader;
integrations: ScmIntegrations;
baseUrl?: string;
fetchUrl?: JsonValue;
outputPath: string;
}) {
if (typeof fetchUrl !== 'string') {
throw new InputError(
`Invalid url parameter, expected string, got ${typeof fetchUrl}`,
);
}
let fetchUrlIsAbsolute = false;
try {
// eslint-disable-next-line no-new
new URL(fetchUrl);
fetchUrlIsAbsolute = true;
} catch {
/* ignored */
}
// We handle both file locations and url ones
if (!fetchUrlIsAbsolute && baseUrl?.startsWith('file://')) {
const basePath = baseUrl.slice('file://'.length);
if (isAbsolute(fetchUrl)) {
throw new InputError(
`Fetch URL may not be absolute for file locations, ${fetchUrl}`,
);
}
const srcDir = resolvePath(basePath, '..', fetchUrl);
await fs.copy(srcDir, outputPath);
} else {
let readUrl;
if (fetchUrlIsAbsolute) {
readUrl = fetchUrl;
} else if (baseUrl) {
const integration = integrations.byUrl(baseUrl);
if (!integration) {
throw new InputError(`No integration found for location ${baseUrl}`);
}
readUrl = integration.resolveUrl({
url: fetchUrl,
base: baseUrl,
});
} else {
throw new InputError(
`Failed to fetch, template location could not be determined and the fetch URL is relative, ${fetchUrl}`,
);
}
const res = await urlReader.readTree(readUrl);
await fs.ensureDir(outputPath);
await res.dir({ targetDir: outputPath });
}
}
export function createFetchPlainAction(options: {
urlReader: UrlReader;
integrations: ScmIntegrations;
}): TemplateAction {
const { urlReader, integrations } = options;
return {
id: 'fetch:plain',
async handler(ctx) {
ctx.logger.info('Fetching plain content from remote URL');
// Finally move the template result into the task workspace
const targetPath = ctx.parameters.targetPath ?? './';
if (typeof targetPath !== 'string') {
throw new InputError(
`Fetch action targetPath is not a string, got ${targetPath}`,
);
}
const outputPath = resolvePath(ctx.workspacePath, targetPath);
if (!outputPath.startsWith(ctx.workspacePath)) {
throw new InputError(
`Fetch action targetPath may not specify a path outside the working directory`,
);
}
await fetchContents({
urlReader,
integrations,
baseUrl: ctx.baseUrl,
fetchUrl: ctx.parameters.url,
outputPath,
});
},
};
}
export function createFetchCookiecutterAction(options: {
dockerClient: Docker;
urlReader: UrlReader;
integrations: ScmIntegrations;
templaters: TemplaterBuilder;
}): TemplateAction {
const { dockerClient, urlReader, templaters, integrations } = options;
return {
id: 'fetch:cookiecutter',
async handler(ctx) {
ctx.logger.info('Fetching and then templating using cookiecutter');
const workDir = await ctx.createTemporaryDirectory();
const templateDir = resolvePath(workDir, 'template');
const templateContentsDir = resolvePath(
templateDir,
"{{cookiecutter and 'contents'}}",
);
const resultDir = resolvePath(workDir, 'result');
await fetchContents({
urlReader,
integrations,
baseUrl: ctx.baseUrl,
fetchUrl: ctx.parameters.url,
outputPath: templateContentsDir,
});
const cookiecutter = templaters.get('cookiecutter');
if (!cookiecutter) {
throw new Error('No cookiecutter templater available');
}
// Will execute the template in ./template and put the result in ./result
await cookiecutter.run({
workspacePath: workDir,
dockerClient,
logStream: ctx.logStream,
values: ctx.parameters.values as TemplaterValues,
});
// Finally move the template result into the task workspace
const targetPath = ctx.parameters.targetPath ?? './';
if (typeof targetPath !== 'string') {
throw new InputError(
`Fetch action targetPath is not a string, got ${targetPath}`,
);
}
const outputPath = resolvePath(ctx.workspacePath, targetPath);
if (!outputPath.startsWith(ctx.workspacePath)) {
throw new InputError(
`Fetch action targetPath may not specify a path outside the working directory`,
);
}
await fs.copy(resultDir, outputPath);
},
};
}
export function createPublishGithubAction(options: {
integrations: ScmIntegrations;
repoVisibility: 'private' | 'internal' | 'public';
}): TemplateAction {
const { integrations, repoVisibility } = options;
const credentialsProviders = new Map(
integrations.github.list().map(integration => {
const provider = GithubCredentialsProvider.create(integration.config);
return [integration.config.host, provider];
}),
);
return {
id: 'publish:github',
async handler(ctx) {
const { repoUrl, description, access } = ctx.parameters;
if (typeof repoUrl !== 'string') {
throw new Error(
`Invalid repo URL passed to publish:github, got ${typeof repoUrl}`,
);
}
let parsed;
try {
parsed = new URL(`https://${repoUrl}`);
} catch (error) {
throw new InputError(
`Invalid repo URL passed to publish:github, got ${repoUrl}, ${error}`,
);
}
const host = parsed.host;
const owner = parsed.searchParams.get('owner');
if (!owner) {
throw new InputError(
`Invalid repo URL passed to publish:github, missing owner`,
);
}
const repo = parsed.searchParams.get('repo');
if (!repo) {
throw new InputError(
`Invalid repo URL passed to publish:github, missing repo`,
);
}
const credentialsProvider = credentialsProviders.get(host);
const integrationConfig = integrations.github.byHost(host);
if (!credentialsProvider || !integrationConfig) {
throw new InputError(
`No matching integration configuration for host ${host}, please check your Integrations config`,
);
}
const { token } = await credentialsProvider.getCredentials({
url: `${host}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`,
});
if (!token) {
throw new InputError(
`No token available for host: ${host}, with owner ${owner}, and repo ${repo}`,
);
}
const client = new Octokit({
auth: token,
baseUrl: integrationConfig.config.apiBaseUrl,
});
const user = await client.users.getByUsername({
username: owner,
});
const repoCreationPromise =
user.data.type === 'Organization'
? client.repos.createInOrg({
name: repo,
org: owner,
private: repoVisibility !== 'public',
visibility: repoVisibility,
description: description as string,
})
: client.repos.createForAuthenticatedUser({
name: repo,
private: repoVisibility === 'private',
description: description as string,
});
const { data } = await repoCreationPromise;
const accessString = access as string;
if (accessString?.startsWith(`${owner}/`)) {
const [, team] = accessString.split('/');
await client.teams.addOrUpdateRepoPermissionsInOrg({
org: owner,
team_slug: team,
owner,
repo,
permission: 'admin',
});
// no need to add accessString if it's the person who own's the personal account
} else if (accessString && accessString !== owner) {
await client.repos.addCollaborator({
owner,
repo,
username: accessString,
permission: 'admin',
});
}
const remoteUrl = data.clone_url;
const repoContentsUrl = `${data?.html_url}/blob/master`;
await initRepoAndPush({
dir: ctx.workspacePath,
remoteUrl,
auth: {
username: 'x-access-token',
password: token,
},
logger: ctx.logger,
});
ctx.output('remoteUrl', remoteUrl);
ctx.output('repoContentsUrl', repoContentsUrl);
},
};
}
export function createCatalogRegisterAction(options: {
catalogClient: CatalogApi;
integrations: ScmIntegrations;
}): TemplateAction {
const { catalogClient, integrations } = options;
return {
id: 'catalog:register',
async handler(ctx) {
const {
repoContentsUrl,
catalogInfoPath = '/catalog-info.yaml',
} = ctx.parameters;
let { catalogInfoUrl } = ctx.parameters;
if (!catalogInfoUrl) {
const integration = integrations.byUrl(repoContentsUrl as string);
if (!integration) {
throw new InputError('No integration found for host');
}
catalogInfoUrl = integration.resolveUrl({
base: repoContentsUrl as string,
url: catalogInfoPath as string,
});
}
ctx.logger.info(`Registering ${catalogInfoUrl} in the catalog`);
const result = await catalogClient.addLocation({
type: 'url',
target: catalogInfoUrl as string,
});
if (result.entities.length >= 1) {
const { kind, name, namespace } = getEntityName(result.entities[0]);
ctx.output('entityRef', `${kind}:${namespace}/${name}`);
ctx.output('catalogInfoUrl', catalogInfoUrl);
}
},
};
}
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { createCatalogRegisterAction } from './register';
@@ -0,0 +1,63 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { InputError } from '@backstage/backend-common';
import { ScmIntegrations } from '@backstage/integration';
import { CatalogApi } from '@backstage/catalog-client';
import { getEntityName } from '@backstage/catalog-model';
import { TemplateAction } from '../../types';
export function createCatalogRegisterAction(options: {
catalogClient: CatalogApi;
integrations: ScmIntegrations;
}): TemplateAction {
const { catalogClient, integrations } = options;
return {
id: 'catalog:register',
async handler(ctx) {
const {
repoContentsUrl,
catalogInfoPath = '/catalog-info.yaml',
} = ctx.parameters;
let { catalogInfoUrl } = ctx.parameters;
if (!catalogInfoUrl) {
const integration = integrations.byUrl(repoContentsUrl as string);
if (!integration) {
throw new InputError('No integration found for host');
}
catalogInfoUrl = integration.resolveUrl({
base: repoContentsUrl as string,
url: catalogInfoPath as string,
});
}
ctx.logger.info(`Registering ${catalogInfoUrl} in the catalog`);
const result = await catalogClient.addLocation({
type: 'url',
target: catalogInfoUrl as string,
});
if (result.entities.length >= 1) {
const { kind, name, namespace } = getEntityName(result.entities[0]);
ctx.output('entityRef', `${kind}:${namespace}/${name}`);
ctx.output('catalogInfoUrl', catalogInfoUrl);
}
},
};
}
@@ -0,0 +1,83 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import { resolve as resolvePath } from 'path';
import Docker from 'dockerode';
import { InputError, UrlReader } from '@backstage/backend-common';
import { ScmIntegrations } from '@backstage/integration';
import { TemplaterBuilder, TemplaterValues } from '../../../stages/templater';
import { TemplateAction } from '../../types';
import { fetchContents } from './helpers';
export function createFetchCookiecutterAction(options: {
dockerClient: Docker;
urlReader: UrlReader;
integrations: ScmIntegrations;
templaters: TemplaterBuilder;
}): TemplateAction {
const { dockerClient, urlReader, templaters, integrations } = options;
return {
id: 'fetch:cookiecutter',
async handler(ctx) {
ctx.logger.info('Fetching and then templating using cookiecutter');
const workDir = await ctx.createTemporaryDirectory();
const templateDir = resolvePath(workDir, 'template');
const templateContentsDir = resolvePath(
templateDir,
"{{cookiecutter and 'contents'}}",
);
const resultDir = resolvePath(workDir, 'result');
await fetchContents({
urlReader,
integrations,
baseUrl: ctx.baseUrl,
fetchUrl: ctx.parameters.url,
outputPath: templateContentsDir,
});
const cookiecutter = templaters.get('cookiecutter');
if (!cookiecutter) {
throw new Error('No cookiecutter templater available');
}
// Will execute the template in ./template and put the result in ./result
await cookiecutter.run({
workspacePath: workDir,
dockerClient,
logStream: ctx.logStream,
values: ctx.parameters.values as TemplaterValues,
});
// Finally move the template result into the task workspace
const targetPath = ctx.parameters.targetPath ?? './';
if (typeof targetPath !== 'string') {
throw new InputError(
`Fetch action targetPath is not a string, got ${targetPath}`,
);
}
const outputPath = resolvePath(ctx.workspacePath, targetPath);
if (!outputPath.startsWith(ctx.workspacePath)) {
throw new InputError(
`Fetch action targetPath may not specify a path outside the working directory`,
);
}
await fs.copy(resultDir, outputPath);
},
};
}
@@ -0,0 +1,86 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import { resolve as resolvePath, isAbsolute } from 'path';
import { InputError, UrlReader } from '@backstage/backend-common';
import { ScmIntegrations } from '@backstage/integration';
import { JsonValue } from '@backstage/config';
export async function fetchContents({
urlReader,
integrations,
baseUrl,
fetchUrl = '.',
outputPath,
}: {
urlReader: UrlReader;
integrations: ScmIntegrations;
baseUrl?: string;
fetchUrl?: JsonValue;
outputPath: string;
}) {
if (typeof fetchUrl !== 'string') {
throw new InputError(
`Invalid url parameter, expected string, got ${typeof fetchUrl}`,
);
}
let fetchUrlIsAbsolute = false;
try {
// eslint-disable-next-line no-new
new URL(fetchUrl);
fetchUrlIsAbsolute = true;
} catch {
/* ignored */
}
// We handle both file locations and url ones
if (!fetchUrlIsAbsolute && baseUrl?.startsWith('file://')) {
const basePath = baseUrl.slice('file://'.length);
if (isAbsolute(fetchUrl)) {
throw new InputError(
`Fetch URL may not be absolute for file locations, ${fetchUrl}`,
);
}
const srcDir = resolvePath(basePath, '..', fetchUrl);
await fs.copy(srcDir, outputPath);
} else {
let readUrl;
if (fetchUrlIsAbsolute) {
readUrl = fetchUrl;
} else if (baseUrl) {
const integration = integrations.byUrl(baseUrl);
if (!integration) {
throw new InputError(`No integration found for location ${baseUrl}`);
}
readUrl = integration.resolveUrl({
url: fetchUrl,
base: baseUrl,
});
} else {
throw new InputError(
`Failed to fetch, template location could not be determined and the fetch URL is relative, ${fetchUrl}`,
);
}
const res = await urlReader.readTree(readUrl);
await fs.ensureDir(outputPath);
await res.dir({ targetDir: outputPath });
}
}
@@ -0,0 +1,18 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { createFetchPlainAction } from './plain';
export { createFetchCookiecutterAction } from './cookiecutter';
@@ -0,0 +1,57 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { resolve as resolvePath } from 'path';
import { InputError, UrlReader } from '@backstage/backend-common';
import { ScmIntegrations } from '@backstage/integration';
import { TemplateAction } from '../../types';
import { fetchContents } from './helpers';
export function createFetchPlainAction(options: {
urlReader: UrlReader;
integrations: ScmIntegrations;
}): TemplateAction {
const { urlReader, integrations } = options;
return {
id: 'fetch:plain',
async handler(ctx) {
ctx.logger.info('Fetching plain content from remote URL');
// Finally move the template result into the task workspace
const targetPath = ctx.parameters.targetPath ?? './';
if (typeof targetPath !== 'string') {
throw new InputError(
`Fetch action targetPath is not a string, got ${targetPath}`,
);
}
const outputPath = resolvePath(ctx.workspacePath, targetPath);
if (!outputPath.startsWith(ctx.workspacePath)) {
throw new InputError(
`Fetch action targetPath may not specify a path outside the working directory`,
);
}
await fetchContents({
urlReader,
integrations,
baseUrl: ctx.baseUrl,
fetchUrl: ctx.parameters.url,
outputPath,
});
},
};
}
@@ -0,0 +1,19 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './catalog';
export * from './fetch';
export * from './publish';
@@ -0,0 +1,152 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { InputError } from '@backstage/backend-common';
import {
GithubCredentialsProvider,
ScmIntegrations,
} from '@backstage/integration';
import { Octokit } from '@octokit/rest';
import { TemplateAction } from '../../types';
import { initRepoAndPush } from '../../../stages/publish/helpers';
export function createPublishGithubAction(options: {
integrations: ScmIntegrations;
repoVisibility: 'private' | 'internal' | 'public';
}): TemplateAction {
const { integrations, repoVisibility } = options;
const credentialsProviders = new Map(
integrations.github.list().map(integration => {
const provider = GithubCredentialsProvider.create(integration.config);
return [integration.config.host, provider];
}),
);
return {
id: 'publish:github',
async handler(ctx) {
const { repoUrl, description, access } = ctx.parameters;
if (typeof repoUrl !== 'string') {
throw new Error(
`Invalid repo URL passed to publish:github, got ${typeof repoUrl}`,
);
}
let parsed;
try {
parsed = new URL(`https://${repoUrl}`);
} catch (error) {
throw new InputError(
`Invalid repo URL passed to publish:github, got ${repoUrl}, ${error}`,
);
}
const host = parsed.host;
const owner = parsed.searchParams.get('owner');
if (!owner) {
throw new InputError(
`Invalid repo URL passed to publish:github, missing owner`,
);
}
const repo = parsed.searchParams.get('repo');
if (!repo) {
throw new InputError(
`Invalid repo URL passed to publish:github, missing repo`,
);
}
const credentialsProvider = credentialsProviders.get(host);
const integrationConfig = integrations.github.byHost(host);
if (!credentialsProvider || !integrationConfig) {
throw new InputError(
`No matching integration configuration for host ${host}, please check your Integrations config`,
);
}
const { token } = await credentialsProvider.getCredentials({
url: `${host}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`,
});
if (!token) {
throw new InputError(
`No token available for host: ${host}, with owner ${owner}, and repo ${repo}`,
);
}
const client = new Octokit({
auth: token,
baseUrl: integrationConfig.config.apiBaseUrl,
});
const user = await client.users.getByUsername({
username: owner,
});
const repoCreationPromise =
user.data.type === 'Organization'
? client.repos.createInOrg({
name: repo,
org: owner,
private: repoVisibility !== 'public',
visibility: repoVisibility,
description: description as string,
})
: client.repos.createForAuthenticatedUser({
name: repo,
private: repoVisibility === 'private',
description: description as string,
});
const { data } = await repoCreationPromise;
const accessString = access as string;
if (accessString?.startsWith(`${owner}/`)) {
const [, team] = accessString.split('/');
await client.teams.addOrUpdateRepoPermissionsInOrg({
org: owner,
team_slug: team,
owner,
repo,
permission: 'admin',
});
// no need to add accessString if it's the person who own's the personal account
} else if (accessString && accessString !== owner) {
await client.repos.addCollaborator({
owner,
repo,
username: accessString,
permission: 'admin',
});
}
const remoteUrl = data.clone_url;
const repoContentsUrl = `${data?.html_url}/blob/master`;
await initRepoAndPush({
dir: ctx.workspacePath,
remoteUrl,
auth: {
username: 'x-access-token',
password: token,
},
logger: ctx.logger,
});
ctx.output('remoteUrl', remoteUrl);
ctx.output('repoContentsUrl', repoContentsUrl);
},
};
}
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { createPublishGithubAction } from './github';
@@ -14,11 +14,6 @@
* limitations under the License.
*/
export {
createCatalogRegisterAction,
createFetchCookiecutterAction,
createFetchPlainAction,
createPublishGithubAction,
} from './builtin';
export * from './builtin';
export { TemplateActionRegistry } from './TemplateActionRegistry';
export type { ActionContext, TemplateAction } from './types';