From dbca48631eb7ae96e444c157d0a446c1560c9c13 Mon Sep 17 00:00:00 2001 From: Ilyes512 Date: Sun, 13 Sep 2026 16:07:34 +0200 Subject: [PATCH] feat: scaffold the repository with a version command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A binary that does exactly one thing: print its version. The specsdeployd Ansible role installs it from a GitHub release, and the install task, the checksum verification and the ConditionPathExists assertion all need something real to point at. Layout mirrors specsnl/labelsync: a thin main.go that turns the command error into an exit code, a cobra tree under internal/cmd with one file per command, the Task + Docker Compose toolchain that pins Go, golangci-lint and Node, and a CI job that runs the same checks. The root's --version prints the bare value, for $(specsdeployd --version); the version subcommand names the binary. Both go through writeVersion, so the two renderings cannot drift. .goreleaser.yml and the Dockerfile inject internal/cmd.Version by its full path in a string no compiler checks — a rename would silently ship every release as "dev", so version_test.go asserts the path against both files. Out of scope, by the issue: the webhook receiver, the config schema, the podman/systemctl execution, and anything that reads /etc/specsdeployd. There is deliberately no runtime container image either — specsdeployd is a host systemd service, and this repo ships only the binary. Refs #1 --- .dockerignore | 18 +++++ .github/workflows/ci.yml | 47 +++++++++++++ .golangci.yml | 14 ++++ .goreleaser.yml | 31 +++++++++ .markdownlint-cli2.yaml | 23 ++++++ Dockerfile | 46 ++++++++++++ README.md | 25 ++++++- Taskfile.dist.yml | 131 +++++++++++++++++++++++++++++++++++ compose.yml | 102 +++++++++++++++++++++++++++ go.mod | 10 +++ go.sum | 10 +++ internal/cmd/root.go | 95 +++++++++++++++++++++++++ internal/cmd/version.go | 48 +++++++++++++ internal/cmd/version_test.go | 102 +++++++++++++++++++++++++++ main.go | 20 ++++++ taskfiles/Taskfile.lint.yml | 17 +++++ taskfiles/Taskfile.md.yml | 27 ++++++++ 17 files changed, 763 insertions(+), 3 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/ci.yml create mode 100644 .golangci.yml create mode 100644 .goreleaser.yml create mode 100644 .markdownlint-cli2.yaml create mode 100644 Dockerfile create mode 100644 Taskfile.dist.yml create mode 100644 compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/cmd/root.go create mode 100644 internal/cmd/version.go create mode 100644 internal/cmd/version_test.go create mode 100644 main.go create mode 100644 taskfiles/Taskfile.lint.yml create mode 100644 taskfiles/Taskfile.md.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..983621a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.github +.env +.DS_Store +/dist +/specsdeployd +taskfiles +.dockerignore +.editorconfig +.gitignore +.golangci.yml +.goreleaser.yml +.markdownlint-cli2.yaml +compose.yml +Dockerfile +LICENSE +README.md +Taskfile.dist.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7faa0f9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit-tests: + name: Unit tests + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + + - name: Cache Go modules + uses: actions/cache@v6 + with: + path: | + ~/.cache/go/pkg/mod + ~/.cache/go/build + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Check go.mod is tidy + run: go mod tidy -diff + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: v2.12 + + - name: Test + run: go test -race -count=1 ./... + + - name: Build (smoke) + run: go build -o /dev/null . diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..0f64b12 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,14 @@ +version: "2" +linters: + default: standard + enable: + - wsl_v5 + - whitespace + - modernize + settings: + errcheck: + # Reporting a failed write would itself need a working stream. + exclude-functions: + - fmt.Fprint + - fmt.Fprintln + - fmt.Fprintf diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..81ef113 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,31 @@ +version: 2 + +builds: + - id: specsdeployd + main: . + binary: specsdeployd + flags: + - -trimpath + - -tags=netgo + ldflags: + - -s -w + - -X github.com/specsnl/specsdeployd/internal/cmd.Version={{ .Version }} + env: + - CGO_ENABLED=0 + # Linux only: the Ansible role that installs this never targets anything else. + goos: [linux] + goarch: [amd64, arm64] + +archives: + - formats: [tar.gz] + name_template: "specsdeployd_{{ .Os }}_{{ .Arch }}" + +checksum: + name_template: "checksums.txt" + +release: + github: + owner: specsnl + name: specsdeployd + draft: false + prerelease: auto diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml new file mode 100644 index 0000000..b5e5650 --- /dev/null +++ b/.markdownlint-cli2.yaml @@ -0,0 +1,23 @@ +# CLI config: https://github.com/DavidAnson/markdownlint-cli2?tab=readme-ov-file#markdownlint-cli2jsonc +# Lint rules: https://github.com/DavidAnson/markdownlint?tab=readme-ov-file#rules--aliases + +config: + line-length: + line_length: 1000 + tables: false + no-inline-html: + allowed_elements: + - dl + - dt + - dd + - details + - summary + - code + ul-style: + style: dash + +globs: + - '**/*.md' + +gitignore: true +showFound: true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7de8cc0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +# syntax=docker/dockerfile:1 +# check=error=true + +# Latest version: https://hub.docker.com/_/golang/tags +FROM --platform=$BUILDPLATFORM golang:1.27.1-trixie AS base + +WORKDIR /src + +RUN apt-get update \ + && apt-get install --assume-yes --no-install-recommends \ + ca-certificates \ + tree \ + git \ + openssh-client + +FROM base AS builder-download + +COPY go.mod . +COPY go.sum . + +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download + +FROM builder-download AS build + +COPY . . + +ARG TARGETOS +ARG TARGETARCH +ARG GOOS +ARG GOARCH +ARG GO_MODULE=github.com/specsnl/specsdeployd +ARG SPECSDEPLOYD_VERSION=dev + +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=${GOOS:-$TARGETOS} GOARCH=${GOARCH:-$TARGETARCH} go build \ + -trimpath \ + -tags netgo \ + -ldflags "-s -w -X ${GO_MODULE}/internal/cmd.Version=${SPECSDEPLOYD_VERSION}" -o ./specsdeployd + +# No runtime image: specsdeployd is a host systemd service driving podman and +# systemctl, so this Dockerfile only ever produces the binary. +FROM scratch AS export + +COPY --from=build /src/specsdeployd /specsdeployd diff --git a/README.md b/README.md index 102ac9c..a0a73a1 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,28 @@ GitHub release. This repository ships **only the binary**. ## Status -Early — nothing is implemented yet. The repository is being scaffolded around a binary that answers -`specsdeployd version` and nothing else, which is enough for the Ansible role to install and assert -against. The webhook receiver lands on top of that. +Early. The binary answers its version and nothing else: + +```sh +specsdeployd version # specsdeployd version 1.2.3 +specsdeployd --version # 1.2.3 +``` + +That is enough for the Ansible role to install it, verify its checksum and assert that it is on the +host. The webhook receiver lands on top of it — no config file is read, no privileged command is +run, and nothing listens on a port yet. + +## Contributing + +Every command runs through [Task](https://taskfile.dev), which wraps the Docker Compose services +that pin the Go, golangci-lint and Node versions — so a check runs the same way locally as it does +in CI. Building from a checkout needs nothing but Docker and Task. Run `task --list` for the full +set. + +```sh +task build # the binary, into the working directory +task checkall # tidy:check, lint, test, md:check — run this before opening a pull request +``` ## License diff --git a/Taskfile.dist.yml b/Taskfile.dist.yml new file mode 100644 index 0000000..6fbd607 --- /dev/null +++ b/Taskfile.dist.yml @@ -0,0 +1,131 @@ +# https://taskfile.dev +version: "3" + +includes: + md: ./taskfiles/Taskfile.md.yml + lint: + taskfile: ./taskfiles/Taskfile.lint.yml + flatten: true + +silent: true + +vars: + # The leading v is stripped to match goreleaser's {{ .Version }}. The fallback + # is captured before the strip, so an undescribable tree yields "dev" rather + # than an empty string. + SPECSDEPLOYD_VERSION: + sh: described=$(git describe --tags --always --dirty="-dev" 2>/dev/null || echo "dev"); echo "${described#v}" + +tasks: + + dc: + desc: Run a docker compose command + internal: true + vars: + FIXUID: + sh: echo ${FIXUID:-$(id -u)} + FIXGID: + sh: echo ${FIXGID:-$(id -g)} + cmds: + - FIXUID={{ .FIXUID }} FIXGID={{ .FIXGID }} docker compose {{ .SUB_CMD }} {{ .CLI_ARGS }} + + dc:build: + desc: Run docker compose build + cmds: + - task: dc + vars: + SUB_CMD: --profile=* build + + dc:run:*: + desc: Run a one-off docker compose command + vars: + SERVICE: '{{ index .MATCH 0 }}' + RUN_FLAGS: '{{ .RUN_FLAGS | default "" }}' + USER_FLAG: '{{ (ne (.NON_ROOT | default "true") "false") | ternary " --user $(id -u):$(id -g)" "" }}' + COMPOSE_SERVICES: + sh: COMPOSE_PROFILES="*" docker compose config --services + cmds: + - task: dc + vars: + SUB_CMD: 'run --rm {{ .RUN_FLAGS }} {{ .USER_FLAG }} {{ .SERVICE }} {{ .SUB_CMD }}' + CLI_ARGS: '{{ .CLI_ARGS }}' + requires: + vars: + - name: SERVICE + enum: + ref: .COMPOSE_SERVICES | splitLines | compact + + dc:shell: + desc: Shell into a docker compose service + cmds: + - task: dc + vars: + SUB_CMD: "run --tty --rm go-builder /bin/bash" + CLI_ARGS: "{{.CLI_ARGS}}" + + build: + desc: Build specsdeployd into the working directory + cmds: + - task: lint + - >- + docker buildx bake --file compose.yml + --set "go-binary.output=type=local,dest=." + --set "go-binary.args.GOARCH={{ARCH}}" + --set "go-binary.args.GOOS={{OS}}" + --set "go-binary.args.SPECSDEPLOYD_VERSION={{.SPECSDEPLOYD_VERSION}}" + go-binary + + tidy:check: + desc: Check that go.mod and go.sum are tidy + summary: | + Fails, without writing anything, when `go mod tidy` would change go.mod or go.sum. + + Run `task tidy` to apply what it reports. + cmds: + - task: dc:run:go-builder + vars: + SUB_CMD: "go mod tidy -diff" + + tidy: + desc: Run go mod tidy + cmds: + - task: dc:run:go-builder + vars: + SUB_CMD: "go mod tidy" + + test: + desc: Run go tests + cmds: + - task: dc:run:go-builder + vars: + SUB_CMD: "go test -race -tags=integration ./..." + + checkall: + desc: Run every check — the tidy check, golangci-lint, the Go tests, and markdownlint + summary: | + The full local check sequence: tidy:check, lint, test, md:check. + + Every step reports; none of them writes. Run this before opening a pull request. + cmds: + - task: tidy:check + - task: lint + - task: test + - task: md:check + + release:dry-run: + desc: Build a snapshot release locally without publishing + cmds: + - task: dc:run:goreleaser + vars: + SUB_CMD: release --snapshot --clean + + cleanup: + desc: Cleanup workspace + summary: | + Cleanup of almost all gitignored files, untracked files and development containers. + + The following files/dirs are excluded: none + cmds: + - git clean + -xd + --force diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..11a0932 --- /dev/null +++ b/compose.yml @@ -0,0 +1,102 @@ +volumes: + cache: + +services: + volume-init: + image: busybox:1.37.0-musl + user: root + volumes: + - cache:/cache + command: + - sh + - -c + - | + mkdir -p /cache/go-build + mkdir -p /cache/go-mod + mkdir -p /cache/golangci-lint + mkdir -p /cache/npm + chown -R ${FIXUID:-1000}:${FIXGID:-1000} /cache + + go-builder: + profiles: ["build"] + user: ${FIXUID:-1000}:${FIXGID:-1000} + build: + context: . + dockerfile: Dockerfile + target: builder-download + environment: + GOCACHE: /cache/go-build + GOMODCACHE: /cache/go-mod + depends_on: + volume-init: + condition: service_completed_successfully + volumes: + - .:/src + - cache:/cache + + go-binary: + profiles: ["build"] + user: ${FIXUID:-1000}:${FIXGID:-1000} + build: + context: . + dockerfile: Dockerfile + target: export + environment: + GOCACHE: /cache/go-build + GOMODCACHE: /cache/go-mod + depends_on: + volume-init: + condition: service_completed_successfully + volumes: + - cache:/cache + + goreleaser: + profiles: ["build"] + user: ${FIXUID:-1000}:${FIXGID:-1000} + # Latest version: https://hub.docker.com/r/goreleaser/goreleaser/tags + image: goreleaser/goreleaser:v2.17.1 + working_dir: /src + environment: + GOCACHE: /cache/go-build + GOMODCACHE: /cache/go-mod + # The image pins GOTOOLCHAIN=local; auto keeps go.mod the single toolchain + # pin, so bumping it does not also mean finding a goreleaser image to match. + GOTOOLCHAIN: auto + depends_on: + volume-init: + condition: service_completed_successfully + volumes: + - .:/src + - cache:/cache + + node: + profiles: ["markdown"] + user: ${FIXUID:-1000}:${FIXGID:-1000} + # Latest version: https://hub.docker.com/_/node/tags + image: node:24.15.0-bookworm + working_dir: /src + environment: + NPM_CONFIG_CACHE: /cache/npm + depends_on: + volume-init: + condition: service_completed_successfully + volumes: + - .:/src + - cache:/cache + + golangci-lint: + profiles: ["lint"] + user: ${FIXUID:-1000}:${FIXGID:-1000} + # Latest version: https://hub.docker.com/r/golangci/golangci-lint/tags + image: golangci/golangci-lint:v2.12.2-alpine + working_dir: /src + environment: + GOCACHE: /cache/go-build + GOMODCACHE: /cache/go-mod + GOLANGCI_LINT_CACHE: /cache/golangci-lint + depends_on: + volume-init: + condition: service_completed_successfully + volumes: + - .:/src + - cache:/cache diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..191ac01 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/specsnl/specsdeployd + +go 1.26.5 + +require github.com/spf13/cobra v1.10.2 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6ee3e0 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cmd/root.go b/internal/cmd/root.go new file mode 100644 index 0000000..0d7731d --- /dev/null +++ b/internal/cmd/root.go @@ -0,0 +1,95 @@ +// Package cmd is the specsdeployd command tree: one file per command, all of +// them leaves on the root built in [NewRootCmd]. +// +// Commands return errors; main turns them into an exit code. Nothing here calls +// os.Exit — it skips deferred cleanup. +package cmd + +import ( + "context" + "io" + "os" + + "github.com/spf13/cobra" +) + +// AppName is how the binary refers to itself in its own output. +const AppName = "specsdeployd" + +const flagVersion = "version" + +// App holds what every command needs beyond its own flags. Commands close over +// it, so a test can point the streams at buffers. +type App struct { + // Out carries a command's product; Err carries a failure, and outlives the + // run because main prints through it after Execute returns. + Out io.Writer + Err io.Writer +} + +// NewApp creates an App over the real process streams. Both are replaced in +// PersistentPreRun by the running command's own streams. +func NewApp() *App { + return &App{ + Out: os.Stdout, + Err: os.Stderr, + } +} + +// Execute builds the command tree and runs it with a background context. +func Execute(app *App) error { + return ExecuteContext(context.Background(), app) +} + +// ExecuteContext builds the command tree and runs it with the given context. +func ExecuteContext(ctx context.Context, app *App) error { + return NewRootCmd(app).ExecuteContext(ctx) +} + +// NewRootCmd builds the root command with every subcommand attached. Exported +// so a test can drive the tree over its own streams. +func NewRootCmd(app *App) *cobra.Command { + cmd := &cobra.Command{ + Use: AppName, + Short: "The deploy agent of the Specs golden images", + Long: `specsdeployd is the deploy agent of the Specs golden images: a host systemd +service that receives GitHub deployment webhooks and rolls the target +application forward. + +None of that is implemented yet. This binary answers "version" and no more. + +Use "specsdeployd --help" for more information about a command.`, + + // Usage after a runtime failure buries the line that matters, and + // Cobra's own error print would duplicate main's. + SilenceUsage: true, + SilenceErrors: true, + + PersistentPreRun: func(cmd *cobra.Command, _ []string) { + app.Out = cmd.OutOrStdout() + app.Err = cmd.ErrOrStderr() + }, + + // Args stays nil so Cobra keeps rejecting unknown subcommands instead of + // showing this help for them. + RunE: func(cmd *cobra.Command, _ []string) error { + if version, _ := cmd.Flags().GetBool(flagVersion); version { + writeVersion(app, true) + + return nil + } + + return cmd.Help() + }, + } + + // Not cmd.Version: Cobra handles its built-in version flag before + // PersistentPreRun, so the value would bypass the command's streams. + cmd.Flags().Bool(flagVersion, false, "Print the bare version and exit") + + cmd.AddCommand( + newVersionCmd(app), + ) + + return cmd +} diff --git a/internal/cmd/version.go b/internal/cmd/version.go new file mode 100644 index 0000000..93f9396 --- /dev/null +++ b/internal/cmd/version.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// Version is the binary version, injected at build time: +// +// -ldflags "-X github.com/specsnl/specsdeployd/internal/cmd.Version=1.2.3" +// +// .goreleaser.yml and the Dockerfile name this variable by that exact path, in +// a string no compiler checks. Rename or move it and every release silently +// ships as "dev". +var Version = "dev" + +func newVersionCmd(app *App) *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the specsdeployd version", + Long: `Print the specsdeployd version. + +The version is a result, not narration, so it goes to stdout and can be +captured: + + specsdeployd version # specsdeployd version 1.2.3 + specsdeployd --version # 1.2.3`, + Args: cobra.NoArgs, + RunE: func(*cobra.Command, []string) error { + writeVersion(app, false) + + return nil + }, + } +} + +// writeVersion is shared with the root's --version so the two renderings of the +// same variable cannot drift. bare drops the surrounding sentence, for +// $(specsdeployd --version). +func writeVersion(app *App, bare bool) { + format := AppName + " version %s\n" + if bare { + format = "%s\n" + } + + fmt.Fprintf(app.Out, format, Version) +} diff --git a/internal/cmd/version_test.go b/internal/cmd/version_test.go new file mode 100644 index 0000000..b229ae4 --- /dev/null +++ b/internal/cmd/version_test.go @@ -0,0 +1,102 @@ +package cmd_test + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/specsnl/specsdeployd/internal/cmd" +) + +func run(t *testing.T, args ...string) (stdout, stderr string, err error) { + t.Helper() + + var out, errOut bytes.Buffer + + root := cmd.NewRootCmd(cmd.NewApp()) + root.SetOut(&out) + root.SetErr(&errOut) + root.SetArgs(args) + + err = root.Execute() + + return out.String(), errOut.String(), err +} + +func TestVersion(t *testing.T) { + for _, tc := range []struct { + name string + args []string + want string + }{ + {"the subcommand names the binary", []string{"version"}, "specsdeployd version " + cmd.Version + "\n"}, + {"the root flag is bare", []string{"--version"}, cmd.Version + "\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + stdout, stderr, err := run(t, tc.args...) + if err != nil { + t.Fatalf("%v: %v", tc.args, err) + } + + if stdout != tc.want { + t.Errorf("stdout = %q, want %q", stdout, tc.want) + } + + if stderr != "" { + t.Errorf("version narrated on stderr: %q", stderr) + } + }) + } +} + +func TestVersion_DefaultsToDev(t *testing.T) { + if cmd.Version != "dev" { + t.Errorf("Version = %q, want %q", cmd.Version, "dev") + } +} + +// The build files name the variable in a string no compiler checks, so a rename +// would ship every release as "dev" with nothing failing. +func TestVersion_BuildFilesInjectThisVariable(t *testing.T) { + const ( + module = "github.com/specsnl/specsdeployd" + want = module + "/internal/cmd.Version" + ) + + for _, file := range []string{"../../.goreleaser.yml", "../../Dockerfile"} { + content, err := os.ReadFile(file) + if err != nil { + t.Fatalf("read %s: %v", file, err) + } + + // The Dockerfile builds the path from ${GO_MODULE}, so match the suffix too. + if !strings.Contains(string(content), want) && + !strings.Contains(string(content), strings.TrimPrefix(want, module)) { + t.Errorf("%s does not inject %s", file, want) + } + } +} + +func TestVersion_RejectsArguments(t *testing.T) { + if _, _, err := run(t, "version", "extra"); err == nil { + t.Fatal("want an error for an unexpected argument, got none") + } +} + +func TestRoot_BareInvocationPrintsHelp(t *testing.T) { + stdout, _, err := run(t) + if err != nil { + t.Fatalf("bare invocation: %v", err) + } + + if !strings.Contains(stdout, "Usage:") { + t.Errorf("stdout = %q, want the help", stdout) + } +} + +func TestRoot_UnknownCommandStillFails(t *testing.T) { + if _, _, err := run(t, "bogus"); err == nil { + t.Fatal("want an error for an unknown command, got none") + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..e66061e --- /dev/null +++ b/main.go @@ -0,0 +1,20 @@ +// Command specsdeployd is the deploy agent of the Specs golden images. It +// currently answers "version" and no more. +package main + +import ( + "fmt" + "os" + + "github.com/specsnl/specsdeployd/internal/cmd" +) + +func main() { + app := cmd.NewApp() + + if err := cmd.Execute(app); err != nil { + fmt.Fprintln(app.Err, "Error:", err) + + os.Exit(1) + } +} diff --git a/taskfiles/Taskfile.lint.yml b/taskfiles/Taskfile.lint.yml new file mode 100644 index 0000000..c1acea0 --- /dev/null +++ b/taskfiles/Taskfile.lint.yml @@ -0,0 +1,17 @@ +# https://taskfile.dev +version: "3" + +tasks: + lint: + desc: Run golangci-lint + cmds: + - task: dc:run:golangci-lint + vars: + SUB_CMD: "golangci-lint run" + + lint:fix: + desc: Run golangci-lint with --fix + cmds: + - task: dc:run:golangci-lint + vars: + SUB_CMD: "golangci-lint run --fix" diff --git a/taskfiles/Taskfile.md.yml b/taskfiles/Taskfile.md.yml new file mode 100644 index 0000000..ee8df09 --- /dev/null +++ b/taskfiles/Taskfile.md.yml @@ -0,0 +1,27 @@ +# https://taskfile.dev +version: "3" + +tasks: + + check: + desc: Check the style of Markdown files with markdownlint + cmds: + - task: :dc:run:node + vars: + SUB_CMD: "npx --yes markdownlint-cli2 {{.SUB_CMD}}" + + fix: + desc: Fix Markdown style — align tables, then apply autofixable lint rules + cmds: + - task: fix-tables + - task: check + vars: + SUB_CMD: --fix + + fix-tables: + desc: Align table spacing in Markdown files + cmds: + # dotglob so hidden directories (e.g. .github) are covered too. + - task: :dc:run:node + vars: + SUB_CMD: bash -c "shopt -s globstar dotglob && npx --yes markdown-table-formatter **/*.md"