From cfd90744bc5ce3f159a5a7ece851758194e6d198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Thu, 31 Aug 2023 02:16:43 +0200 Subject: [PATCH] Initial version --- .dockerignore | 4 ++ .drone.jsonnet | 71 ++++++++++++++++++++++ .gitignore | 3 + Dockerfile | 12 ++++ bun.lockb | Bin 0 -> 9785 bytes package.json | 26 +++++++++ src/argv.ts | 18 ++++++ src/env.ts | 16 +++++ src/git.ts | 104 +++++++++++++++++++++++++++++++++ src/index.ts | 41 +++++++++++++ src/matchers/CalVerMatcher.ts | 17 ++++++ src/matchers/DefaultMatcher.ts | 19 ++++++ src/matchers/GitRefMatcher.ts | 8 +++ src/matchers/SemVerMatcher.ts | 56 ++++++++++++++++++ src/matchers/StubMatcher.ts | 12 ++++ src/matchers/index.ts | 31 ++++++++++ src/tags.ts | 18 ++++++ tsconfig.json | 21 +++++++ 18 files changed, 477 insertions(+) create mode 100644 .dockerignore create mode 100644 .drone.jsonnet create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100755 bun.lockb create mode 100644 package.json create mode 100644 src/argv.ts create mode 100644 src/env.ts create mode 100644 src/git.ts create mode 100644 src/index.ts create mode 100644 src/matchers/CalVerMatcher.ts create mode 100644 src/matchers/DefaultMatcher.ts create mode 100644 src/matchers/GitRefMatcher.ts create mode 100644 src/matchers/SemVerMatcher.ts create mode 100644 src/matchers/StubMatcher.ts create mode 100644 src/matchers/index.ts create mode 100644 src/tags.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3ef40a9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +/.git +/node_modules +/test-repo +/.tags diff --git a/.drone.jsonnet b/.drone.jsonnet new file mode 100644 index 0000000..e762cf5 --- /dev/null +++ b/.drone.jsonnet @@ -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), + ], + }, +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab152ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/node_modules +/test-repo +/.tags diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9480aa9 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..7135984a3b614ba6e1dea9cbf621c45f3488c48b GIT binary patch literal 9785 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 +export type MainPath = Opaque +export type GitPath = Opaque + +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, +) + +export type Argv = S.To + +export const parseArgv = () => + S.parse(ArgvSchema)(process.argv) diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..4c2947a --- /dev/null +++ b/src/env.ts @@ -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 + +export const parseEnv = () => + S.parse(EnvSchema)(process.env) diff --git a/src/git.ts b/src/git.ts new file mode 100644 index 0000000..d1312b5 --- /dev/null +++ b/src/git.ts @@ -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() + +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 +export type GitRefCommitHash = Opaque + +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), + ) + ), +) diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..00f6ec1 --- /dev/null +++ b/src/index.ts @@ -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, + ) +) diff --git a/src/matchers/CalVerMatcher.ts b/src/matchers/CalVerMatcher.ts new file mode 100644 index 0000000..283a0b7 --- /dev/null +++ b/src/matchers/CalVerMatcher.ts @@ -0,0 +1,17 @@ +import { Effect } from "effect" +import { GitRef } from "../git" +import { GitRefMatcher } from "./GitRefMatcher" + + +export class CalVerMatcher + extends GitRefMatcher { + + constructor(private format: string) { + super() + } + + match(_ref: GitRef) { + return Effect.fail(new Error("Not implemented")) + } + +} diff --git a/src/matchers/DefaultMatcher.ts b/src/matchers/DefaultMatcher.ts new file mode 100644 index 0000000..38212b5 --- /dev/null +++ b/src/matchers/DefaultMatcher.ts @@ -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 { + + match(ref: GitRef) { + return Effect.succeed( + Option.some([ + ref.name, + `${ ref.name }-${ ref.commit }`, + ] as DockerTag[]) + ) + } + +} diff --git a/src/matchers/GitRefMatcher.ts b/src/matchers/GitRefMatcher.ts new file mode 100644 index 0000000..5034cb9 --- /dev/null +++ b/src/matchers/GitRefMatcher.ts @@ -0,0 +1,8 @@ +import { Effect, Option } from "effect" +import { GitRef } from "../git" +import { DockerTag } from "../tags" + + +export abstract class GitRefMatcher { + abstract match(ref: GitRef): Effect.Effect> +} diff --git a/src/matchers/SemVerMatcher.ts b/src/matchers/SemVerMatcher.ts new file mode 100644 index 0000000..f166042 --- /dev/null +++ b/src/matchers/SemVerMatcher.ts @@ -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 { + + 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 + }, + + 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) ) + } + +} diff --git a/src/matchers/StubMatcher.ts b/src/matchers/StubMatcher.ts new file mode 100644 index 0000000..0b4b223 --- /dev/null +++ b/src/matchers/StubMatcher.ts @@ -0,0 +1,12 @@ +import { Effect, Option } from "effect" +import { GitRefMatcher } from "./GitRefMatcher" + + +export class StubMatcher + extends GitRefMatcher { + + match() { + return Effect.succeed(Option.none()) + } + +} diff --git a/src/matchers/index.ts b/src/matchers/index.ts new file mode 100644 index 0000000..55dde28 --- /dev/null +++ b/src/matchers/index.ts @@ -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 = ( + ref: GitRef, + matchers: GitRefMatcher[], +): Effect.Effect => + 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)) + }) + ) + ) diff --git a/src/tags.ts b/src/tags.ts new file mode 100644 index 0000000..ca7e864 --- /dev/null +++ b/src/tags.ts @@ -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 + + +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 }`), + }) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..29f8aa0 --- /dev/null +++ b/tsconfig.json @@ -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 + ] + } +}