diff --git a/.github/workflows/js-app.yaml b/.github/workflows/js-app.yaml new file mode 100644 index 00000000..26e10a67 --- /dev/null +++ b/.github/workflows/js-app.yaml @@ -0,0 +1,76 @@ +name: js-app + +on: + workflow_call: + inputs: + build-command: + description: 'NPM project build command. Defaults to build' + type: string + default: 'build' + required: false + skip-unit-tests: + description: 'Set to true if project needs to skip running unit tests' + type: boolean + default: false + required: false + skip-e2e-tests: + description: 'Set to true if project needs to skip running browser tests' + type: boolean + default: false + required: false + app-deploy-env: + type: string + description: 'Use "production", "dev", "test", "staging", etc' + required: false + default: "production" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: read + +jobs: + js-app-build: + name: Build nextjs app + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '>=1.24.0' + cache-dependency-path: "**/go.sum" + + - name: Install go Tools + run: go install tool + + - name: Setup Node.js (for the JS project) + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Build app and Push to docker repository + run: "go tool mage -v buildAndPublish" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_COMMAND: ${{ inputs.build-command }} + DEPLOY_ENV: ${{ inputs.app-deploy-env }} + SKIP_UNIT_TEST: ${{ github.skip-unit-tests }} + SKIP_E2E_TEST: ${{ github.skip-e2e-tests }} + + - id: oci-images + name: Output OCI images references + run: | + if [ -f ./var/oci-images.json ]; then + echo "images=$(cat ./var/oci-images.json)" >> $GITHUB_OUTPUT + else + echo "images={}" >> $GITHUB_OUTPUT + fi + - name: Show output + run: tree var/ + diff --git a/.github/workflows/js-lib.yaml b/.github/workflows/js-lib.yaml new file mode 100644 index 00000000..c30e3fd1 --- /dev/null +++ b/.github/workflows/js-lib.yaml @@ -0,0 +1,67 @@ +name: mage + +on: + workflow_call: + inputs: + skip-build: + description: 'Set to true if project do not need to be build' + type: boolean + default: false + required: false + build-command: + description: 'NPM project build command. Defaults to build:library' + type: string + default: 'build:library' + required: false + skip-unit-tests: + description: 'Set to true if want to skip unit tests' + type: boolean + default: false + required: false + private: + description: 'Set to true if npm package to be publish is a private' + type: boolean + required: false + default: false + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: read + +jobs: + publish-js-lib: + name: Publish to github npm repository + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '>=1.24.0' + cache-dependency-path: "**/go.sum" + + - name: Install go Tools + run: go install tool + + - name: Setup Node.js (for the JS project) + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Build and publish + run: go tool mage -v buildAndPublish + env: + BUILD_COMMAND: ${{ github.build-command }} + SKIP_BUILD: ${{ github.skip-build }} + SKIP_UNIT_TEST: ${{ github.skip-unit-tests }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PRIVATE: ${{ inputs.is-npm-package-private }} + GITHUB_TAGNAME: ${{ github.event.release.tag_name }} + + diff --git a/internal/core/core.go b/internal/core/core.go index ace4f3d9..e18515e9 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -2,7 +2,9 @@ package core import ( "fmt" + "io" "io/fs" + "log" "os" "path" "path/filepath" @@ -120,3 +122,45 @@ func CompareChangesToPaths(changes []string, paths []string, additionalGlobs []s } return false, nil } + +// FileExists checks if the file in given path exists or not. +func FileExists(path string) bool { + _, err := os.Stat(path) + + return err == nil +} + +// CreateOrAppendFile creates a file with a given content or appends content to existing file +// at the specified path +func CreateOrAppendFile(path string, content string) error { + file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + + defer func() { + if err := file.Close(); err != nil { + log.Printf("Failed to close file: %v", err) + } + }() + + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + + if _, err := io.WriteString(file, content); err != nil { + return fmt.Errorf("failed to write to file: %w", err) + } + + return nil +} + +// IsDirectoryEmpty checks if the specified directory is empty +// and returns true if it contains no files or subdirectories. +// Also return error if there is any while reading the directory +func IsDirectoryEmpty(dirPath string) (bool, error) { + entries, err := os.ReadDir(dirPath) + + if err != nil { + return true, err + } + + return len(entries) == 0, nil +} diff --git a/internal/docker/docker.go b/internal/docker/docker.go index a62966fb..ac87228a 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -7,6 +7,7 @@ import ( "os" "path" "path/filepath" + "strconv" "strings" "time" @@ -21,6 +22,12 @@ const ( imageNameBaseFallback = "ocreg.invalid/coopnorge" ) +const ( + // PushEnv is the name of the environmental variable used to trigger + // pushing of OCI images. Set PUSH_IMAGE to true to push images. + PushEnv = "PUSH_IMAGE" +) + // Validate the content of a Dockerfile func Validate(dockerfileContent string) error { dockerfilePath, cleanup, err := core.WriteTempFile("./var", "Dockerfile", dockerfileContent) @@ -90,6 +97,24 @@ func BuildAndPush(dockerfileContent, platforms, image, dockerContext, imagePath, ) } + githubToken := os.Getenv("GITHUB_TOKEN") + + if githubToken != "" { + filename, cleanup, err := core.WriteTempFile(".", "github_token", githubToken) + + if err != nil { + return err + } + + args = append( + args, + "--secret", + fmt.Sprintf("id=github_token,src=%s", filename), + ) + + defer cleanup() + } + args = append(args, "-f", dockerfilePath, dockerContext, @@ -192,14 +217,31 @@ func Images(imageDir string) (AppImages, error) { result[metadata.App][metadata.Binary]["tag"] = metadata.Tag result[metadata.App][metadata.Binary]["image"] = metadata.ImageName } + return result, nil } // FullyQualifiedlImageName ... func FullyQualifiedlImageName(app, binary string) string { + if binary == "" { + return fmt.Sprintf("%s/%s", imageBase(), app) + } return fmt.Sprintf("%s/%s/%s", imageBase(), app, binary) } +// ShouldPush checks if docker image should be pushed to the repository or not +func ShouldPush() (bool, error) { + val, ok := os.LookupEnv(PushEnv) + if !ok || val == "" { + return false, nil + } + boolValue, err := strconv.ParseBool(val) + if err != nil { + return false, err + } + return boolValue, nil +} + func imageBase() string { imageBase, ok := os.LookupEnv(imageBaseEnv) if !ok || imageBase == "" { diff --git a/internal/git/git.go b/internal/git/git.go index 458aa42e..ddad3430 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -3,11 +3,29 @@ package git import ( "fmt" "os" + "path" "strings" "github.com/magefile/mage/sh" ) +// RepoNameFromURL returns the repository name +func RepoNameFromURL() (string, error) { + url, err := RepoURL() + + if err != nil { + return "", err + } + + // 1. Get the last element of the URL path + base := path.Base(url) + + // 2. Remove the ".git" suffix if it exists + projectName := strings.TrimSuffix(base, ".git") + + return projectName, nil +} + // RepoURL returns the remote URL to the git repository func RepoURL() (string, error) { remote, err := sh.Output("git", "remote", "get-url", "origin") diff --git a/internal/targets/javascript/javascript.go b/internal/targets/javascript/javascript.go new file mode 100644 index 00000000..66e0e7a7 --- /dev/null +++ b/internal/targets/javascript/javascript.go @@ -0,0 +1,201 @@ +package javascript + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/coopnorge/mage/internal/core" + "github.com/magefile/mage/sh" +) + +// Install fetches all Node.js dependencies. +func Install() error { + if HasPackageConfig() { + tokenName := "GITHUB_TOKEN" + tokenValue := os.Getenv(tokenName) + + env := map[string]string{} + + if tokenValue != "" && IsNpmrcConfiguredForPrivateRepo() { + env[tokenName] = tokenValue + } + + if err := sh.RunWithV(env, "npm", "install"); err != nil { + return fmt.Errorf("dependency installation failed: %w", err) + } + } + + return nil +} + +// Lint runs the standard linting script defined in package.json. +func Lint() error { + envData := os.Getenv("SKIP_LINT") + skip := strings.ToLower(envData) == "true" || envData == "1" + + if skip { + return nil + } + + if HasBiomeConfig() { + return errors.New("biome not setup in your project. Install @coopnorge/web-devtools") + } + + if err := sh.RunV("npm", "run", "lint"); err != nil { + return fmt.Errorf("linting failed: %w", err) + } + + return nil +} + +// Format runs the standard formatting check script defined in package.json. +func Format() error { + envData := os.Getenv("SKIP_FORMAT") + skip := strings.ToLower(envData) == "true" || envData == "1" + + if skip { + return nil + } + + if HasBiomeConfig() { + return errors.New("biome not setup in your project. Install @coopnorge/web-devtools") + } + + if err := sh.RunV("npm", "run", "format:code"); err != nil { + return fmt.Errorf("linting failed: %w", err) + } + + return nil +} + +// UnitTest runs unit tests using the package.json script. +func UnitTest() error { + envData := os.Getenv("SKIP_UNIT_TEST") + skip := strings.ToLower(envData) == "true" || envData == "1" + + if skip { + return nil + } + + if err := sh.RunV("npm", "run", "test:unit"); err != nil { + return fmt.Errorf("unit tests failed: %w", err) + } + + return nil +} + +// E2ETest runs End-to-End tests (often separate and slower). +func E2ETest() error { + envData := os.Getenv("SKIP_E2E_TEST") + skip := strings.ToLower(envData) == "true" || envData == "1" + + if skip { + return nil + } + + if err := sh.RunV("npm", "run", "test:e2e"); err != nil { + return fmt.Errorf("E2E tests failed: %w", err) + } + + return nil +} + +// Build compiles the JavaScript/TypeScript into distribution files. +func Build(buildCommand string) error { + envData := os.Getenv("SKIP_BUILD") + skip := strings.ToLower(envData) == "true" || envData == "1" + + if skip { + return nil + } + + if buildCommand == "" { + buildCommand = "build" + } + + if err := sh.RunV("npm", "run", buildCommand); err != nil { + return fmt.Errorf("build failed: %w", err) + } + + return nil +} + +// NpmPublish publishes npm repository into a github packages +func NpmPublish() error { + tokenName := "GITHUB_TOKEN" + tokenValue := os.Getenv(tokenName) + privateEnv := os.Getenv("PRIVATE") + isPrivate := strings.ToLower(privateEnv) == "true" || privateEnv == "1" + githubTagname := os.Getenv("GITHUB_TAGNAME") + newVersion := strings.TrimPrefix(githubTagname, "v") + + env := map[string]string{} + + if tokenValue != "" && IsNpmrcConfiguredForPrivateRepo() { + env[tokenName] = tokenValue + } + + access := "public" + + if isPrivate { + access = "restricted" + } + + if newVersion == "" { + return errors.New("no new package version set. Set GITHUB_TAGNAME env variable") + } + + if !core.FileExists(".npmrc") { + return errors.New(".npmrc file missing") + } + + if !IsNpmrcConfiguredForPrivateRepo() { + return errors.New(".npmrc has no auth configuration") + } + + if err := sh.RunV("npm", "run", "version", newVersion); err != nil { + return fmt.Errorf("bumping version failed: %w", err) + } + + if err := sh.RunWithV(env, "npm", "publish", "--access", access); err != nil { + return fmt.Errorf("bumping version failed: %w", err) + } + + return nil +} + +// IsNpmrcConfiguredForPrivateRepo checks if the project is setup for private +// repositories +func IsNpmrcConfiguredForPrivateRepo() bool { + directory := "." + + registryURL := "npm.pkg.github.com" + scope := "@coopnorge" + tokenIndicator := "_authToken=" + + npmrcContent, err := os.ReadFile(fmt.Sprintf("%s/.npmrc", directory)) + + if err != nil { + return false + } + + contentStr := string(npmrcContent) + + if !strings.Contains(contentStr, registryURL) && !strings.Contains(contentStr, scope) && !strings.Contains(contentStr, tokenIndicator) { + return false + } + + return true +} + +// HasBiomeConfig checks if project has biome setup +func HasBiomeConfig() bool { + return core.FileExists("biome.json") +} + +// HasPackageConfig checks if project has package.json file +func HasPackageConfig() bool { + return core.FileExists("package.json") +} diff --git a/targets/jsapp/app.Dockerfile b/targets/jsapp/app.Dockerfile new file mode 100644 index 00000000..31bc167a --- /dev/null +++ b/targets/jsapp/app.Dockerfile @@ -0,0 +1,49 @@ +FROM node:lts-slim@sha256:fe64023c6490eb001c7a28e9f92ef8deb6e40e1b7fc5352d695dcaef59e1652d AS builder + +ARG BUILD_SCRIPT=build + +WORKDIR /app +COPY . . + +RUN --mount=type=secret,id=github_token \ + GITHUB_TOKEN=$(cat /run/secrets/github_token) npm install + +RUN npm run ${BUILD_SCRIPT} + +FROM builder AS runner +WORKDIR /app + +# Uncomment the following line in case you want to disable telemetry during runtime. +ENV NEXT_TELEMETRY_DISABLED=1 + +ARG GROUP=nodejs +ARG USER=nextjs +ARG DISTFOLDER=.next + +RUN addgroup --system --gid 1001 ${GROUP} +RUN adduser --system --uid 1001 ${USER} + +COPY --from=builder --chown=${USER}:${GROUP} /app/${DISTFOLDER}/standalone ./ +COPY --from=builder --chown=${USER}:${GROUP} /app/${DISTFOLDER}/static ./static +COPY --from=builder --chown=${USER}:${GROUP} /app/public ./public + + +ARG GIT_REPOSITORY_URL +ARG GIT_COMMIT_SHA + + +LABEL org.opencontainers.image.source=${GIT_REPOSITORY_URL} +LABEL org.opencontainers.image.revision=${GIT_COMMIT_SHA} + +USER ${USER}:${GROUP} + +ENV DD_GIT_REPOSITORY_URL=${GIT_REPOSITORY_URL} +ENV DD_GIT_COMMIT_SHA=${GIT_COMMIT_SHA} +EXPOSE 3000 +ENV PORT=3000 + +# server.js is created by next build from the standalone output +# https://nextjs.org/docs/pages/api-reference/next-config-js/output +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/targets/jsapp/javascript.go b/targets/jsapp/javascript.go new file mode 100644 index 00000000..3e913bd2 --- /dev/null +++ b/targets/jsapp/javascript.go @@ -0,0 +1,148 @@ +package jsapp + +import ( + "context" + _ "embed" + "encoding/json" + "os" + "path" + + "github.com/coopnorge/mage/internal/core" + "github.com/coopnorge/mage/internal/docker" + "github.com/coopnorge/mage/internal/git" + "github.com/coopnorge/mage/internal/targets/javascript" + "github.com/magefile/mage/mg" +) + +var ( + //go:embed app.Dockerfile + dockerfile string +) + +const ( + platforms = "linux/amd64,linux/arm64" +) + +// JavaScript is the magefile namespace to group javascript/typescript commands +type JavaScript mg.Namespace + +// BuildAndPush OCI image. Setting push to true will push the images to the +// registries. When push is true images are not tagged with latest. +// +// [BuildApp] will create: +// +// ./var +// ├── oci-images.json +// └── app +// └── oci +// ├── production +// │   ├── image.tar +// │   └── metadata.json +// └── testing +// ├── image.tar +// └── metadata.json +// +// oci-images.json will contain a map over the images and tags for app per +// environment. Use case: We add data-test-id for automating browser testing. +// These are quite a lot of ids and we remove them for production build/env. +// +// { +// "app": { +// "testing": { +// "image": "ocreg.invalid/coopnorge/app/testing:v2025.03.11135857", +// "tag": "v2025.03.11135857" +// }, +// "production": { +// "image": "ocreg.invalid/coopnorge/app1/production:v2025.03.11135857", +// "tag": "v2025.03.11135857" +// } +// } +// } + +// BuildAndPushDockerImage creates deployable artifacts from the source code in the repository, +// to push the resulting images set the environmental variable PUSH_IMAGE to +// true. Setting PUSH_IMAGE to true will disable the latest image tag. +func (JavaScript) BuildAndPushDockerImage(ctx context.Context) error { + shouldPush, err := docker.ShouldPush() + if err != nil { + return err + } + + mg.SerialCtxDeps(ctx, mg.F(buildAndPush, shouldPush)) + return writeImageMetadata() +} + +// Install fetches all Node.js dependencies. +func (JavaScript) Install(ctx context.Context) error { + mg.CtxDeps(ctx, javascript.Install) + return nil +} + +// Lint runs the standard linting script defined in package.json. +func (JavaScript) Lint(ctx context.Context) error { + mg.CtxDeps(ctx, javascript.Lint) + return nil +} + +// Format runs the standard formatting check script defined in package.json. +func (JavaScript) Format(ctx context.Context) error { + mg.CtxDeps(ctx, javascript.Format) + return nil +} + +// UnitTest unit tests using the package.json script. +func (JavaScript) UnitTest(ctx context.Context) error { + mg.CtxDeps(ctx, javascript.UnitTest) + return nil +} + +// E2ETest runs browser tests using the package.json script. +func (JavaScript) E2ETest(ctx context.Context) error { + mg.CtxDeps(ctx, javascript.E2ETest) + return nil +} + +func buildAndPush(shouldPush bool) error { + env := os.Getenv("DEPLOY_ENV") + + if env == "" { + env = "production" + } + + app, err := git.RepoNameFromURL() + + if err != nil { + return err + } + + imageName := docker.FullyQualifiedlImageName(app, env) + imagePath := imagePath(app, env) + metadataPath := metadataPath(app, env) + + return docker.BuildAndPush(dockerfile, platforms, imageName, ".", imagePath, metadataPath, app, env, shouldPush) +} + +func imageDir(app string, env string) string { + return path.Join(core.OutputDir, app, "oci", env) +} + +func imagePath(app string, env string) string { + return path.Join(imageDir(app, env), "image.tar") +} + +func metadataPath(app string, env string) string { + return path.Join(imageDir(app, env), "metadata.json") +} + +func writeImageMetadata() error { + images, err := docker.Images(core.OutputDir) + if err != nil { + return err + } + + jsonString, err := json.Marshal(images) + if err != nil { + return err + } + return os.WriteFile(path.Join(core.OutputDir, "oci-images.json"), jsonString, 0644) +} diff --git a/targets/jsapp/main.go b/targets/jsapp/main.go new file mode 100644 index 00000000..54e61118 --- /dev/null +++ b/targets/jsapp/main.go @@ -0,0 +1,45 @@ +package jsapp + +import ( + "context" + + "github.com/magefile/mage/mg" +) + +// BuildAndPublish creates deployable artifacts from the source code in the repository, +// to push the resulting images set the environmental variable PUSH_IMAGE to +// true. Setting PUSH_IMAGE to true will disable the latest image tag. +func BuildAndPublish(ctx context.Context) error { + mg.SerialCtxDeps(ctx, Install, Lint, Format, UnitTest, E2ETest, JavaScript.BuildAndPushDockerImage) + return nil +} + +// Install fetches all Node.js dependencies. +func Install(ctx context.Context) error { + mg.CtxDeps(ctx, JavaScript.Install) + return nil +} + +// Lint runs the standard linting script defined in package.json. +func Lint(ctx context.Context) error { + mg.CtxDeps(ctx, JavaScript.Lint) + return nil +} + +// Format runs the standard formatting check script defined in package.json. +func Format(ctx context.Context) error { + mg.CtxDeps(ctx, JavaScript.Format) + return nil +} + +// UnitTest unit tests using the package.json script. +func UnitTest(ctx context.Context) error { + mg.CtxDeps(ctx, JavaScript.UnitTest) + return nil +} + +// E2ETest runs browser tests using the package.json script. +func E2ETest(ctx context.Context) error { + mg.CtxDeps(ctx, JavaScript.E2ETest) + return nil +} diff --git a/targets/jslib/javascript.go b/targets/jslib/javascript.go new file mode 100644 index 00000000..8618ee78 --- /dev/null +++ b/targets/jslib/javascript.go @@ -0,0 +1,47 @@ +package jslib + +import ( + "context" + + "github.com/coopnorge/mage/internal/targets/javascript" + "github.com/magefile/mage/mg" +) + +// JavaScript is the magefile namespace to group javascript/typescript commands +type JavaScript mg.Namespace + +// Install fetches all Node.js dependencies. +func (JavaScript) Install(ctx context.Context) error { + mg.CtxDeps(ctx, javascript.Install) + return nil +} + +// Lint runs the standard linting script defined in package.json. +func (JavaScript) Lint(ctx context.Context) error { + mg.CtxDeps(ctx, javascript.Lint) + return nil +} + +// Format runs the standard formatting check script defined in package.json. +func (JavaScript) Format(ctx context.Context) error { + mg.CtxDeps(ctx, javascript.Format) + return nil +} + +// UnitTest runs unit tests using the package.json script. +func (JavaScript) UnitTest(ctx context.Context) error { + mg.CtxDeps(ctx, javascript.UnitTest) + return nil +} + +// Build compiles the JavaScript/TypeScript into distribution files. +func (JavaScript) Build(ctx context.Context) error { + mg.CtxDeps(ctx, mg.F(javascript.Build, "build:library")) + return nil +} + +// NpmPublish publishes npm repository into a github packages +func (JavaScript) NpmPublish(ctx context.Context) error { + mg.CtxDeps(ctx, mg.F(javascript.NpmPublish, "build:library")) + return nil +} diff --git a/targets/jslib/main.go b/targets/jslib/main.go new file mode 100644 index 00000000..29612f33 --- /dev/null +++ b/targets/jslib/main.go @@ -0,0 +1,38 @@ +package jslib + +import ( + "context" + + "github.com/magefile/mage/mg" +) + +// BuildAndPublish checks for linting and formatting issue runs for unit test +// if not skipped and builds the project and publish it to the npm repository +func BuildAndPublish(ctx context.Context) error { + mg.SerialCtxDeps(ctx, Lint, Format, UnitTest, JavaScript.Build, JavaScript.NpmPublish) + return nil +} + +// Install fetches all Node.js dependencies. +func Install(ctx context.Context) error { + mg.CtxDeps(ctx, JavaScript.Install) + return nil +} + +// Lint runs the standard linting script defined in package.json. +func Lint(ctx context.Context) error { + mg.CtxDeps(ctx, JavaScript.Lint) + return nil +} + +// Format runs the standard formatting check script defined in package.json. +func Format(ctx context.Context) error { + mg.CtxDeps(ctx, JavaScript.Format) + return nil +} + +// UnitTest unit tests using the package.json script. +func UnitTest(ctx context.Context) error { + mg.CtxDeps(ctx, JavaScript.UnitTest) + return nil +}