Initial version
Some checks failed
continuous-integration/drone Build is passing
continuous-integration/drone/tag Build is failing

This commit is contained in:
Julien Valverdé
2023-08-31 02:16:43 +02:00
parent 416a133d82
commit cfd90744bc
18 changed files with 477 additions and 0 deletions

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
/.git
/node_modules
/test-repo
/.tags

71
.drone.jsonnet Normal file
View File

@@ -0,0 +1,71 @@
local bun_image = "oven/bun:0.8.1";
local install_run_step = {
name: "install-run",
image: bun_image,
commands: [
"apt update -y && apt full-upgrade -y && apt install -y --no-install-recommends git",
"bun install --production --frozen-lockfile --no-cache",
"bun start .",
],
};
local build_docker_step(publish) = {
name: "build-" + (if publish then "publish-" else "") + "docker",
image: "plugins/docker",
settings: {
dry_run: !publish,
registry: "git.jvalver.de",
username: { from_secret: "docker_username" },
password: { from_secret: "docker_password" },
repo: "git.jvalver.de/thilawyn/drone-better-docker-autotag",
dockerfile: "Dockerfile",
context: ".",
compress: true,
platform: "linux/amd64",
},
};
[
// Build docker images without publishing them for pull requests
{
kind: "pipeline",
type: "docker",
name: "build-docker",
trigger: {
ref: {
include: ["refs/pull/**"]
}
},
steps: [
install_run_step,
build_docker_step(false),
],
},
// Build docker images and publish them for master and tags
{
kind: "pipeline",
type: "docker",
name: "build-publish-docker",
trigger: {
ref: {
include: [
"refs/heads/master",
"refs/tags/**",
]
}
},
steps: [
install_run_step,
build_docker_step(true),
],
},
]

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/node_modules
/test-repo
/.tags

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM oven/bun:0.8.1
RUN apt update -y && \
apt full-upgrade -y && \
apt install -y --no-install-recommends git && \
apt autoremove -y --purge && \
apt clean
COPY ./ ./
RUN bun install --production --frozen-lockfile --no-cache && \
rm -rf ~/.bun
ENTRYPOINT ["bun", "start", "/drone/src"]

BIN
bun.lockb Executable file

Binary file not shown.

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "drone-better-docker-autotag",
"module": "index.ts",
"type": "module",
"scripts": {
"start": "NODE_ENV=production bun src/index.ts",
"start:dev": "NODE_ENV=development bun src/index.ts"
},
"devDependencies": {
"@types/lodash-es": "^4.17.8",
"@types/semver": "^7.5.0",
"bun-types": "latest",
"type-fest": "^4.2.0"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"@effect/schema": "^0.33.2",
"effect": "^2.0.0-next.29",
"lodash-es": "^4.17.21",
"semver": "^7.5.4",
"simple-git": "^3.19.1",
"trim-newlines": "^5.0.0"
}
}

18
src/argv.ts Normal file
View File

@@ -0,0 +1,18 @@
import * as S from "@effect/schema/Schema"
import { Opaque } from "type-fest"
export type BunPath = Opaque<string, "BunPath">
export type MainPath = Opaque<string, "MainPath">
export type GitPath = Opaque<string, "RepoPath">
export const ArgvSchema = S.tuple(
S.literal<[BunPath]>(Bun.which("bun") as BunPath),
S.literal<[MainPath]>(Bun.main as MainPath),
S.string as unknown as S.Schema<GitPath, GitPath>,
)
export type Argv = S.To<typeof ArgvSchema>
export const parseArgv = () =>
S.parse(ArgvSchema)(process.argv)

16
src/env.ts Normal file
View File

@@ -0,0 +1,16 @@
import * as S from "@effect/schema/Schema"
export const EnvSchema = S.struct({
NODE_ENV: S.union(
S.literal("development"),
S.literal("production"),
),
// PLUGIN_CALVER_FORMAT: S.optional(S.string),
})
export type Env = S.To<typeof EnvSchema>
export const parseEnv = () =>
S.parse(EnvSchema)(process.env)

104
src/git.ts Normal file
View File

@@ -0,0 +1,104 @@
import { Context, Effect, pipe } from "effect"
import { GitConstructError, GitError, GitPluginError, GitResponseError, SimpleGit } from "simple-git"
import { trimNewlines } from "trim-newlines"
import { Opaque } from "type-fest"
export const SimpleGitService = Context.Tag<SimpleGit>()
function mapSimpleGitErrors(e: unknown): GitError
| GitConstructError
| GitPluginError
| GitResponseError
| Error
{
if (e instanceof GitError
|| e instanceof GitConstructError
|| e instanceof GitPluginError
|| e instanceof GitResponseError)
return e
return new Error(`simpleGit failed with a unknown error: ${ e }`)
}
export type GitRefName = Opaque<string, "GitRefName">
export type GitRefCommitHash = Opaque<string, "GitRefCommitHash">
export class GitRef {
constructor(
readonly name: GitRefName,
readonly commit: GitRefCommitHash,
) {}
}
export class GitBranchRef extends GitRef {}
export class GitTagRef extends GitRef {}
export class GitRefNullNameError {}
export const getGitRefName = pipe(
SimpleGitService,
Effect.flatMap(git =>
Effect.tryPromise({
try: async () =>
(await git.status()).current as GitRefName | null,
catch: mapSimpleGitErrors,
})
),
Effect.flatMap(name =>
name
? Effect.succeed(name)
: Effect.fail(new GitRefNullNameError)
),
)
export const getGitRefTagName = pipe(
SimpleGitService,
Effect.flatMap(git =>
Effect.tryPromise({
try: async () =>
trimNewlines( await git.raw("describe", "--tags", "--exact-match") ) as GitRefName,
catch: mapSimpleGitErrors,
})
),
)
export const getGitRefCommitHash = pipe(
SimpleGitService,
Effect.flatMap(git =>
Effect.tryPromise({
try: async () =>
trimNewlines( await git.raw("show", "--format=%h", "--no-patch") ) as GitRefCommitHash,
catch: mapSimpleGitErrors,
})
),
)
export const getGitRef = pipe(
Effect.all([
getGitRefName,
getGitRefCommitHash,
]),
Effect.flatMap(([name, commit]) =>
name !== "HEAD" ?
Effect.succeed(new GitBranchRef(name, commit) as GitRef)
:
pipe(
getGitRefTagName,
Effect.map(name => new GitTagRef(name, commit) as GitRef),
)
),
)

41
src/index.ts Normal file
View File

@@ -0,0 +1,41 @@
import { Console, Context, Effect, pipe } from "effect"
import simpleGit from "simple-git"
import { parseArgv } from "./argv"
import { parseEnv } from "./env"
import { SimpleGitService, getGitRef } from "./git"
import { DefaultMatcher, SemVerMatcher, generateDockerTagsFromGitRef } from "./matchers"
import { writeDockerTagsFile } from "./tags"
const [ _env, [,, gitPath] ] = await Effect.runPromise(
Effect.all([parseEnv(), parseArgv()])
)
const context = Context.empty().pipe(
Context.add(
SimpleGitService,
SimpleGitService.of(simpleGit(gitPath)),
)
)
const main = pipe(
getGitRef,
Effect.flatMap(ref => generateDockerTagsFromGitRef(ref, [
new SemVerMatcher,
new DefaultMatcher,
])),
Effect.tap(tags =>
Console.log(`Generated tags: ${ tags.join(", ") }`)
),
Effect.flatMap(tags => writeDockerTagsFile(gitPath, tags)),
)
await Effect.runPromise(
Effect.provideContext(
main,
context,
)
)

View File

@@ -0,0 +1,17 @@
import { Effect } from "effect"
import { GitRef } from "../git"
import { GitRefMatcher } from "./GitRefMatcher"
export class CalVerMatcher
extends GitRefMatcher<never, Error> {
constructor(private format: string) {
super()
}
match(_ref: GitRef) {
return Effect.fail(new Error("Not implemented"))
}
}

View File

@@ -0,0 +1,19 @@
import { Effect, Option } from "effect"
import { GitRef } from "../git"
import { DockerTag } from "../tags"
import { GitRefMatcher } from "./GitRefMatcher"
export class DefaultMatcher
extends GitRefMatcher<never, never> {
match(ref: GitRef) {
return Effect.succeed(
Option.some([
ref.name,
`${ ref.name }-${ ref.commit }`,
] as DockerTag[])
)
}
}

View File

@@ -0,0 +1,8 @@
import { Effect, Option } from "effect"
import { GitRef } from "../git"
import { DockerTag } from "../tags"
export abstract class GitRefMatcher<R, E> {
abstract match(ref: GitRef): Effect.Effect<R, E, Option.Option<DockerTag[]>>
}

View File

@@ -0,0 +1,56 @@
import { Effect, Option } from "effect"
import { isEmpty, reverse, tail } from "lodash-es"
import semver from "semver"
import { GitRef, GitTagRef } from "../git"
import { DockerTag } from "../tags"
import { GitRefMatcher } from "./GitRefMatcher"
export class SemVerMatcher
extends GitRefMatcher<never, TypeError | Error> {
match(ref: GitRef) {
return (ref instanceof GitTagRef && semver.valid(ref.name)) ?
Effect.try({
try: () => {
const prerelease = semver.prerelease(ref.name)
return Option.some(
!prerelease ? [
"latest",
...this.generateTags([
semver.major(ref.name),
semver.minor(ref.name),
semver.patch(ref.name),
]),
] :
this.generateTags(prerelease).map(tag =>
`${ semver.coerce(ref.name) }-${ tag }` as DockerTag
)
) as Option.Option<DockerTag[]>
},
catch: (e): TypeError | Error => {
if (e instanceof TypeError)
return e
return new Error(`An unknown error happened while parsing SemVer: ${ e }`)
},
})
:
Effect.succeed(Option.none())
}
private generateTags(numbers: readonly (string | number)[]) {
const rec = (numbers: readonly (string | number)[]): DockerTag[] =>
isEmpty(numbers)
? []
: [
...rec( tail(numbers) ),
reverse(numbers).join(".") as DockerTag,
]
return rec( reverse(numbers) )
}
}

View File

@@ -0,0 +1,12 @@
import { Effect, Option } from "effect"
import { GitRefMatcher } from "./GitRefMatcher"
export class StubMatcher
extends GitRefMatcher<never, never> {
match() {
return Effect.succeed(Option.none())
}
}

31
src/matchers/index.ts Normal file
View File

@@ -0,0 +1,31 @@
import { Effect, Option } from "effect"
import { isEmpty, tail } from "lodash-es"
import { GitRef } from "../git"
import { DockerTag } from "../tags"
import { GitRefMatcher } from "./GitRefMatcher"
export * from "./CalVerMatcher"
export * from "./DefaultMatcher"
export * from "./GitRefMatcher"
export * from "./SemVerMatcher"
export * from "./StubMatcher"
export class NoMatcherFoundError {}
export const generateDockerTagsFromGitRef = <R, E>(
ref: GitRef,
matchers: GitRefMatcher<R, E>[],
): Effect.Effect<R, E | NoMatcherFoundError, DockerTag[]> =>
isEmpty(matchers) ?
Effect.fail(new NoMatcherFoundError)
:
matchers[0].match(ref).pipe(
Effect.flatMap(res =>
Option.match(res, {
onSome: Effect.succeed,
onNone: () => generateDockerTagsFromGitRef(ref, tail(matchers))
})
)
)

18
src/tags.ts Normal file
View File

@@ -0,0 +1,18 @@
import { Effect } from "effect"
import path from "node:path"
import { Opaque } from "type-fest"
import { GitPath } from "./argv"
export type DockerTag = Opaque<string, "Tag">
export const writeDockerTagsFile = (gitPath: GitPath, tags: DockerTag[]) =>
Effect.tryPromise({
try: async () => {
await Bun.write( path.join(gitPath, ".tags"), tags.join(",") )
},
catch: e =>
new Error(`Could not write file '${ path.join(gitPath, ".tags") }': ${ e }`),
})

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"module": "esnext",
"target": "esnext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"allowImportingTsExtensions": true,
"strict": true,
"downlevelIteration": true,
"skipLibCheck": true,
"jsx": "preserve",
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"noEmit": true,
"types": [
"bun-types" // add Bun global
]
}
}