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 0000000..7135984 Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..490bfd8 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/argv.ts b/src/argv.ts new file mode 100644 index 0000000..0a571c8 --- /dev/null +++ b/src/argv.ts @@ -0,0 +1,18 @@ +import * as S from "@effect/schema/Schema" +import { Opaque } from "type-fest" + + +export type BunPath = Opaque +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 + ] + } +}