diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 1784d3b..0000000 --- a/.dockerignore +++ /dev/null @@ -1,3 +0,0 @@ -Containerfile -docker-compose.yaml -*.md diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 57b3e9a..336efe3 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -1,75 +1,81 @@ -# I mostly copied this straight from here: -# https://docs.github.com/en/actions/publishing-packages/publishing-docker-images - -name: main +--- +name: "main" on: push: branches: - - main + - "main" pull_request: - branches: - - main + types: + - "opened" + - "reopened" + - "synchronize" + +env: + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + MISE_GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" jobs: ci: - runs-on: ubuntu-latest + runs-on: "ubuntu-latest" permissions: - contents: read - packages: write - + contents: "read" steps: - - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # release v5.0.0 - - name: Install system packages + - name: "Checkout PR branch" + if: "github.event_name == 'pull_request'" + uses: "actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8" # v5.0.0 + with: + fetch-depth: 0 + ref: "${{ github.event.pull_request.head.ref }}" + - name: "Checkout main branch" + if: "github.ref == 'refs/heads/main'" + uses: "actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8" # v5.0.0 + with: + fetch-depth: 0 + - name: "Install system packages" run: | sudo apt-get update sudo apt-get install -y \ make - - name: Set up mise - uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # release v3.2.0 + - name: "Set up mise" + uses: "jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566" # v3.2.0 with: - version: "2025.8.1" + version: "2025.9.10" install: true # runs `mise install` cache: true - - name: Run CI - run: make ci - - # - name: Set up Docker Buildx - # uses: docker/setup-buildx-action@v2 - - # - name: Log in to GHCR - # uses: docker/login-action@v2 - # with: - # registry: ghcr.io - # username: ${{ github.repository_owner }} - # password: ${{ secrets.GITHUB_TOKEN }} - - # # Generate any tags we want for the images (https://github.com/docker/build-push-action/blob/master/docs/advanced/tags-labels.md) - # - name: Extract metadata (tags, labels) for OCI image - # id: meta - # uses: docker/metadata-action@v4 - # with: - # images: ghcr.io/opensourcecorp/oscar - # tags: | - # type=sha - - # # non-mainline - # - name: Build and push non-mainline OCI image - # if: github.ref != 'refs/heads/main' - # uses: docker/build-push-action@v3 - # with: - # context: . - # file: Containerfile - # push: true - # tags: ${{ steps.meta.outputs.tags }} - - # # mainline - # - name: Build and push mainline OCI image - # if: github.ref == 'refs/heads/main' - # uses: docker/build-push-action@v3 - # with: - # context: . - # file: Containerfile - # push: true - # tags: ghcr.io/opensourcecorp/oscar:latest + - name: "Run CI Tasks" + run: "make ci" + deliver: + if: "github.ref == 'refs/heads/main'" + runs-on: "ubuntu-latest" + permissions: + contents: "write" + packages: "write" + steps: + - name: "Checkout PR branch" + if: "github.event_name == 'pull_request'" + uses: "actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8" # v5.0.0 + with: + fetch-depth: 0 + ref: "${{ github.event.pull_request.head.ref }}" + - name: "Checkout main branch" + if: "github.ref == 'refs/heads/main'" + uses: "actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8" # v5.0.0 + with: + fetch-depth: 0 + - name: "Install system packages" + run: | + sudo apt-get update + sudo apt-get install -y \ + make + - name: "Set up mise" + uses: "jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566" # v3.2.0 + with: + version: "2025.9.10" + install: true # runs `mise install` + cache: true + - name: "Set up Docker" + uses: "docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435" # v3.11.1 + - name: "Run Delivery Tasks" + run: "make deliver" diff --git a/.gitignore b/.gitignore index 798c450..82b0a5d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,11 @@ +.oscar/ .vscode/ -*cache* *.log - *.out -build/ -dist/ *.tar.gz *.zip - -.oscar/ +*cache* +build/ +dist/ +mise.*.toml +scratch/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 559fcff..e32f5ea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,15 +2,19 @@ ## Workstation setup -`oscar`'s development leans heavily into using the root-level Makefile. +`oscar`'s development is the most consistent if you use the following: -Development of `oscar` is the most consistent if your workstation has [`mise`](https://mise.jdx.dev) -available on your `$PATH`. However, the Makefile's targets are configured to also allow for working -with most tooling natively, such as Go, if they are available. +* The root-level Makefile. +* [`mise`](https://mise.jdx.dev), via the root-level `mise.toml`. + +While not strictly required, you will have a much better time if you leverage those. However, the +Makefile's targets are configured to also allow for working with most tooling natively, such as Go, +if they are available. `mise` does not manage Docker Engine, however, so for targets like `make image` you will need to -ensure that you have a container runtime available (like Docker or Podman, overridable via the -`DOCKER` Make variable). +ensure that you have a container runtime available (like the ones for Docker or Podman). The default +command the Makefile tries to run is `docker`, which you can override via the `DOCKER` Make +variable. ## Contribution philosophy @@ -18,5 +22,17 @@ ensure that you have a container runtime available (like Docker or Podman, overr * As a reminder, `oscar`'s runtime behavior is ***intentionally designed to be rigid***. If there is a language or tool you would like to see added, then those contributions are welcome. Fundamental - changes to how `oscar` intends to operate, e.g. allowing for wholesale override of various - linters' settings, are not. + changes to how `oscar` intends to operate, e.g. allowing for user override of a linter's + line-length checks, or which directory `oscar` builds binaries into, are not. + +* `hEy tHeRe'S a LoT oF sHeLL cOdE iN hErE?!` -- Correct. "Shelling out" is an intentional design + decision, and `oscar` calls out `bash` as a dependency. + +## Adding support for a new Tool + +To add a new Tool to `oscar`, it must: + +* Define a non-exported struct, which embeds `taskutil.Tool`. +* That struct must implement the `taskutil.Tasker` interface. + +See examples across the various `internal/tasks/tools/{lang}/*.go` files. diff --git a/Containerfile b/Containerfile index 6f85d76..7eb599d 100644 --- a/Containerfile +++ b/Containerfile @@ -10,10 +10,12 @@ ARG https_proxy FROM docker.io/library/golang:${GO_VERSION} AS builder -RUN apt-get update && apt-get install -y \ +RUN apt-get update && apt-get install --no-install-recommends -y \ bash \ ca-certificates \ - make + make \ + && \ + rm -rf /var/lib/apt* COPY . /go/app WORKDIR /go/app @@ -26,14 +28,16 @@ FROM docker.io/library/debian:13-slim AS ci COPY --from=builder /go/app/build/oscar /oscar -RUN apt-get update && apt-get install -y \ +RUN apt-get update && apt-get install --no-install-recommends -y \ bash \ ca-certificates \ curl \ git \ gnupg2 \ make \ - rename + rename \ + && \ + rm -rf /var/lib/apt/* COPY . /go/app WORKDIR /go/app @@ -54,13 +58,15 @@ COPY --from=builder /go/app/build/oscar /oscar # NOTE: Docker BuildKit will skip stages it doesn't see as dependencies, so to enforce the "ci" # stage above to run, we need to force a dependency here -COPY --from=ci /go/app/VERSION /VERSION +COPY --from=ci /go/app/LICENSE /LICENSE -RUN apt-get update && apt-get install -y \ +RUN apt-get update && apt-get install --no-install-recommends -y \ bash \ ca-certificates \ git \ - gnupg2 + gnupg2 \ + && \ + rm -rf /var/lib/apt/* # NOTE: when creating some shims, mise refers to itself assuming it is on the $PATH, so we need to # symlink it out so it can do that @@ -77,4 +83,7 @@ VOLUME /home/oscar/app # oscar's home directory, for caching on the host VOLUME /home/oscar/.oscar +# So e.g. GitHub can tie the image to its source repo +LABEL org.opencontainers.image.source https://github.com/opensourcecorp/oscar + ENTRYPOINT ["/oscar"] diff --git a/Makefile b/Makefile index c0994c4..f80a9e6 100644 --- a/Makefile +++ b/Makefile @@ -11,60 +11,33 @@ BINNAME := oscar BINPATH := ./cmd/$(BINNAME) DOCKER ?= docker -OCI_REGISTRY ?= ghcr.io -OCI_REGISTRY_OWNER ?= opensourcecorp +export IMAGE_REGISTRY ?= ghcr.io +export IMAGE_REGISTRY_OWNER ?= opensourcecorp +export IMAGE_NAME ?= $(BINNAME) +export IMAGE_TAG ?= latest +export IMAGE_URI ?= $(IMAGE_REGISTRY)/$(IMAGE_REGISTRY_OWNER)/$(IMAGE_NAME):$(IMAGE_TAG) SHELL = /usr/bin/env bash -euo pipefail -.PHONY: % - all: ci +FORCE: + ci: clean @$(RUN) go run ./cmd/$(BINNAME)/main.go ci +deliver: + @$(RUN) go run ./cmd/$(BINNAME)/main.go deliver + # test is just an alias for ci test: ci -ci-container: - @$(DOCKER) build \ - --build-arg http_proxy="$${http_proxy}" \ - --build-arg https_proxy="$${https_proxy}" \ - --build-arg GO_VERSION="$$(awk '/^go/ { print $$2 }' go.mod)" \ - -f ./Containerfile \ - -t $(BINNAME)-test:latest \ - . - -build: clean - @mkdir -p ./build/$$($(RUN) go env GOOS)-$$($(RUN) go env GOARCH) - @$(RUN) go build -o ./build/$(BINNAME) $(BINPATH) - @printf 'built to %s\n' ./build/$(BINNAME) +# NOTE: oscar builds itself IRL, but having a target here makes it easier to have the Containerfile +# have a stage-copiable output +build: FORCE + @$(RUN) go build -o ./build/oscar ./cmd/oscar -xbuild: clean - @for target in \ - darwin-amd64 \ - darwin-arm64 \ - linux-amd64 \ - linux-arm64 \ - ; \ - do \ - GOOS=$$(echo "$${target}" | cut -d'-' -f1) ; \ - GOARCH=$$(echo "$${target}" | cut -d'-' -f2) ; \ - outdir=build/"$${GOOS}-$${GOARCH}" ; \ - mkdir -p "$${outdir}" ; \ - printf "Building for %s-%s into build/ ...\n" "$${GOOS}" "$${GOARCH}" ; \ - GOOS="$${GOOS}" GOARCH="$${GOARCH}" $(RUN) go build -o "$${outdir}"/$(BINNAME) $(BINPATH) ; \ - done - -package: xbuild - @mkdir -p dist - @cd build || exit 1; \ - for built in * ; do \ - printf 'Packaging for %s into dist/ ...\n' "$${built}" ; \ - cd $${built} && tar -czf ../../dist/$(BINNAME)_$${built}.tar.gz * && cd - >/dev/null ; \ - done - -clean: +clean: FORCE @rm -rf \ /tmp/$(BINNAME)-tests \ ./*cache* \ @@ -78,7 +51,10 @@ clean: image: clean @export BUILDKIT_PROGRESS=plain && \ export GO_VERSION="$$(awk '/^go/ { print $$2 }' go.mod)" && \ - $(DOCKER) compose build + $(RUN) $(DOCKER) compose build + +run-image: FORCE + @$(RUN) $(DOCKER) compose run $(BINNAME) -run-image: - @$(DOCKER) compose run $(BINNAME) +generate: FORCE + @cd ./proto && $(RUN) buf generate diff --git a/README.md b/README.md index ce1c629..604132a 100644 --- a/README.md +++ b/README.md @@ -4,39 +4,24 @@ ![Github Actions](https://github.com/opensourcecorp/oscar/actions/workflows/main.yaml/badge.svg) -`oscar` ("OpenSourceCorp Automation Runner") is a highly-opinionated, out-of-the-box task runner. -Originally designed for use exclusively within [OpenSourceCorp's CI/CD -subsystem](https://github.com/opensourcecorp/osc-infra/tree/main/cicd), it is perfectly usable -outside of OSC as well. +`oscar` ("OpenSourceCorp Automation Runner") is a highly-opinionated, out-of-the-box task runner +designed for use across OSC. -`oscar` is "highly-opinionated" in that `oscar` is designed to do each thing ***one single way***. -No choosing what linters to run or how to configure them, no picking which annual flavor of Python -or Nodejs packaging tool, etc. -- `oscar` is built to be ***the*** authoritative toolset for entire -teams and their codebases. +`oscar` is "highly-opinionated" in that it is designed to do each thing ***one single way***. No +choosing what linters to run or how to configure them, no picking which annual flavor of Python or +Nodejs packaging tool, no discrepancies in how to cut & deploy releases, etc. -- `oscar` is built to +be ***the*** authoritative toolset for entire teams and their codebases. -Under the hood, `oscar` uses the excellent [`mise`](https://mise.jdx.dev) quite heavily, and would -like to thank the author & contributors for making something like `oscar` possible without a lot of -wheel-reinvention. - -## How to use - -Before getting started, note that `oscar` has a few host-system runtime dependencies. Some of these -may someday be replaced natively in the future, but some are integral to how `oscar` works -internally. - -* `bash` (version 4.4+) -* `git` +## Features You run oscar by providing it a subcommand, such as `ci`. You can see the full available subcommand list via `oscar --help`. -## Features - -| Feature | `oscar` command | Details | -| :--------------------------- | :-------------- | :--------------------------------- | -| Continuous integration | `oscar ci` | [section](#continuous-integration) | +| Feature | `oscar` command | Details | +| :--------------------- | :-------------- | :--------------------------------- | +| Continuous integration | `oscar ci` | [section](#continuous-integration) | +| Delivery | `oscar deliver` | [section](#delivery) | - ### Continuous Integration @@ -57,24 +42,62 @@ possibly run against within a set of codebases. However, this does not mean that someone is prevented from adding *additional* checks outside of `oscar`'s purview -- it just means that you cannot override what `oscar` *does* control. +### Delivery + +TODO + +| Artifact types | Targets | `oscar.yaml` field | +| :--------------- | :--------------- | :------------------------------- | +| Go binaries | GitHub Releases | `deliverables.go_github_release` | +| Container images | Any OCI registry | `deliverables.container_image` | + + + + +## Requirements + +Before getting started, note that `oscar` has a few host-system runtime dependencies. Some of these +may someday be replaced natively in the future, but some are integral to how `oscar` works +internally. + +* `bash` (version 4.4+) +* GNU `coreutils` +* `git` + +In addition, some components of `oscar` may require additional host-system tools (e.g. a container +runtime like Docker for building & pushing container images). + +If you are running on macOS, you should be able to install any missing tools via `brew install`-ing +the above by name -- but make sure your `$PATH` is pointing to the correct ones and not the default +BSD-equivalents. + ## Supported platforms -`oscar` is designed to run on Linux, and should work on macOS as well. Native Windows has not been -tested, and is unlikely to work. If you are on a Windows machine, you can run `oscar` in a WSL2 +`oscar` is designed to run on Linux, and should mostly work on macOS as well. Native Windows has not +been tested, and is unlikely to work. If you are on a Windows machine, you can run `oscar` in a WSL2 environment and it will work the same as on Linux. ## Development & Contributions Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for details about developing `oscar`. +## Acknowledgements + +Under the hood, `oscar` uses the excellent [`mise`](https://mise.jdx.dev) quite heavily, and would +like to thank the author & contributors for making something like `oscar` possible without a lot of +wheel-reinvention. + ## Roadmap -* Add `VERSION` check comparing to `main` as a CI task +* Add `oscar.yaml` generator +* Add check for changelog Markdown file that matches `oscar.yaml:version` (we should also use that + file as the exact GH Release post contents) * Workstation setup * Have `oscar` manage Makefiles, dotfiles, etc. + * Also have it dump its own `mise.toml` for the user + * `self-update` subcommand * CI additions + * Protobuf (especially since there's proto code in this repo now) * Terraform - * protobuf - * Rust? * CD additions - * Publish to ghcr + * Publishing to ghcr is confirmed to be working when run on `main` branch diff --git a/VERSION b/VERSION deleted file mode 100644 index 6e8bf73..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.1.0 diff --git a/docker-compose.yaml b/docker-compose.yaml index 7dc09cc..1ada3ee 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,3 +1,4 @@ +--- services: oscar: container_name: "oscar" @@ -6,12 +7,13 @@ services: dockerfile: "./Containerfile" args: GO_VERSION: "${GO_VERSION:-1.25.0}" - MISE_VERSION: "${MISE_VERSION:-v2025.8.21}" - http_proxy: "${http_proxy}" - https_proxy: "${https_proxy}" - image: "ghrc.io/opensourcecorp/oscar:latest" - pull_policy: "if_not_present" - command: ["ci"] + MISE_VERSION: "${MISE_VERSION:-v2025.9.10}" + http_proxy: "${http_proxy:-}" + https_proxy: "${https_proxy:-}" + image: "${IMAGE_URI:-}" + pull_policy: "build" + command: + - "ci" environment: http_proxy: "${http_proxy}" https_proxy: "${https_proxy}" diff --git a/embed.go b/embed.go index e450632..c020146 100644 --- a/embed.go +++ b/embed.go @@ -8,5 +8,5 @@ import "embed" // 'mise.toml' file that is used for not only oscar's own development config but also for its // internals. // -//go:embed VERSION mise.toml +//go:embed mise.toml oscar.yaml var Files embed.FS diff --git a/go.mod b/go.mod index 1912f83..1cf2c17 100644 --- a/go.mod +++ b/go.mod @@ -6,3 +6,25 @@ require ( github.com/urfave/cli/v3 v3.4.1 golang.org/x/mod v0.27.0 ) + +require ( + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.9-20250912141014-52f32327d4b0.1 + buf.build/go/protovalidate v1.0.0 + github.com/stretchr/testify v1.11.1 + go.yaml.in/yaml/v4 v4.0.0-rc.2 + google.golang.org/protobuf v1.36.9 +) + +require ( + cel.dev/expr v0.24.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/cel-go v0.26.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stoewer/go-strcase v1.3.1 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/text v0.26.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum index 8151f88..db49db2 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,53 @@ +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.9-20250912141014-52f32327d4b0.1 h1:DQLS/rRxLHuugVzjJU5AvOwD57pdFl9he/0O7e5P294= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.9-20250912141014-52f32327d4b0.1/go.mod h1:aY3zbkNan5F+cGm9lITDP6oxJIwu0dn9KjJuJjWaHkg= +buf.build/go/protovalidate v1.0.0 h1:IAG1etULddAy93fiBsFVhpj7es5zL53AfB/79CVGtyY= +buf.build/go/protovalidate v1.0.0/go.mod h1:KQmEUrcQuC99hAw+juzOEAmILScQiKBP1Oc36vvCLW8= +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= +github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= +github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM= github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= +go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= +go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/ci/configfiles/embed.go b/internal/ci/configfiles/embed.go deleted file mode 100644 index 025d5d3..0000000 --- a/internal/ci/configfiles/embed.go +++ /dev/null @@ -1,10 +0,0 @@ -// Package ciconfig is used for storing embeddable config files for various CI tools, that are -// injected at runtime. -package ciconfig - -import "embed" - -// Files stores config files for each CI tool. -// -//go:embed *.conf *.toml *.yaml -var Files embed.FS diff --git a/internal/ci/configfiles/staticcheck.conf b/internal/ci/configfiles/staticcheck.conf deleted file mode 100644 index 098bfc6..0000000 --- a/internal/ci/configfiles/staticcheck.conf +++ /dev/null @@ -1,5 +0,0 @@ -# All checks available here: -# https://staticcheck.dev/docs/checks/ -checks = [ - "all", -] diff --git a/internal/ci/go/doc.go b/internal/ci/go/doc.go deleted file mode 100644 index 5552ce4..0000000 --- a/internal/ci/go/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package goci contains logic for running CI tasks for Go. -package goci diff --git a/internal/ci/go/tasks.go b/internal/ci/go/tasks.go deleted file mode 100644 index 4044b0f..0000000 --- a/internal/ci/go/tasks.go +++ /dev/null @@ -1,264 +0,0 @@ -package goci - -import ( - "fmt" - "os" - "path/filepath" - "slices" - - ciconfig "github.com/opensourcecorp/oscar/internal/ci/configfiles" - ciutil "github.com/opensourcecorp/oscar/internal/ci/util" -) - -// A list of tasks that all implement [ciutil.Tasker], for Go. -type ( - goModCheckTask struct{} - goFormatTask struct{} - generateCodeTask struct{} - goBuildTask struct{} - goVetTask struct{} - staticcheckTask struct{} - reviveTask struct{} - errcheckTask struct{} - goImportsTask struct{} - govulncheckTask struct{} - goTestTask struct{} -) - -var tasks = []ciutil.Tasker{ - goModCheckTask{}, - goFormatTask{}, - generateCodeTask{}, - goBuildTask{}, - goVetTask{}, - staticcheckTask{}, - reviveTask{}, - errcheckTask{}, - goImportsTask{}, - govulncheckTask{}, - goTestTask{}, -} - -// Tasks returns the list of CI tasks. -func Tasks(repo ciutil.Repo) []ciutil.Tasker { - if repo.HasGo { - return tasks - } - - return nil -} - -// InfoText implements [ciutil.Tasker.InfoText]. -func (t goModCheckTask) InfoText() string { return "go.mod tidy check" } - -// Run implements [ciutil.Tasker.Run]. -func (t goModCheckTask) Run() error { - if err := ciutil.RunCommand([]string{"go", "mod", "tidy"}); err != nil { - return err - } - - return nil -} - -// Post implements [ciutil.Tasker.Post]. -func (t goModCheckTask) Post() error { return nil } - -// InfoText implements [ciutil.Tasker.InfoText]. -func (t goFormatTask) InfoText() string { return "Format" } - -// Run implements [ciutil.Tasker.Run]. -func (t goFormatTask) Run() error { - if err := ciutil.RunCommand([]string{"go", "fmt", "./..."}); err != nil { - return err - } - - return nil -} - -// Post implements [ciutil.Tasker.Post]. -func (t goFormatTask) Post() error { return nil } - -// InfoText implements [ciutil.Tasker.InfoText]. -func (t generateCodeTask) InfoText() string { return "Generate code" } - -// Run implements [ciutil.Tasker.Run]. -func (t generateCodeTask) Run() error { - if err := ciutil.RunCommand([]string{"go", "generate", "./..."}); err != nil { - return err - } - - return nil -} - -// Post implements [ciutil.Tasker.Post]. -func (t generateCodeTask) Post() error { return nil } - -// InfoText implements [ciutil.Tasker.InfoText]. -func (t goBuildTask) InfoText() string { return "Build" } - -// Run implements [ciutil.Tasker.Run]. -func (t goBuildTask) Run() error { - if err := ciutil.RunCommand([]string{"go", "build", "./..."}); err != nil { - return err - } - - return nil -} - -// Post implements [ciutil.Tasker.Post]. -func (t goBuildTask) Post() error { return nil } - -// InfoText implements [ciutil.Tasker.InfoText]. -func (t goVetTask) InfoText() string { return "Vet" } - -// Run implements [ciutil.Tasker.Run]. -func (t goVetTask) Run() error { - if err := ciutil.RunCommand([]string{"go", "vet", "./..."}); err != nil { - return err - } - - return nil -} - -// Post implements [ciutil.Tasker.Post]. -func (t goVetTask) Post() error { return nil } - -// InfoText implements [ciutil.Tasker.InfoText]. -func (t staticcheckTask) InfoText() string { return "Lint (staticcheck)" } - -// Run implements [ciutil.Tasker.Run]. -func (t staticcheckTask) Run() (err error) { - cfgFileContents, err := ciconfig.Files.ReadFile(filepath.Base(staticcheck.ConfigFilePath)) - if err != nil { - return fmt.Errorf("reading embedded file contents: %w", err) - } - - if err := os.WriteFile(staticcheck.ConfigFilePath, cfgFileContents, 0644); err != nil { - return fmt.Errorf("writing config file: %w", err) - } - - if err := goRun(staticcheck, "./..."); err != nil { - return err - } - - return nil -} - -// Post implements [ciutil.Tasker.Post]. -func (t staticcheckTask) Post() error { - if err := os.RemoveAll(staticcheck.ConfigFilePath); err != nil { - return fmt.Errorf("removing config file: %w", err) - } - - return nil -} - -// InfoText implements [ciutil.Tasker.InfoText]. -func (t reviveTask) InfoText() string { return "Lint (revive)" } - -// Run implements [ciutil.Tasker.Run]. -func (t reviveTask) Run() error { - cfgFileContents, err := ciconfig.Files.ReadFile(filepath.Base(revive.ConfigFilePath)) - if err != nil { - return fmt.Errorf("reading embedded file contents: %w", err) - } - - if err := os.WriteFile(revive.ConfigFilePath, cfgFileContents, 0644); err != nil { - return fmt.Errorf("writing config file: %w", err) - } - - args := []string{ - "--config", revive.ConfigFilePath, - "--set_exit_status", - "./...", - } - - if err := goRun(revive, args...); err != nil { - return err - } - - return nil -} - -// Post implements [ciutil.Tasker.Post]. -func (t reviveTask) Post() error { - if err := os.RemoveAll(revive.ConfigFilePath); err != nil { - return fmt.Errorf("removing config file: %w", err) - } - - return nil -} - -// InfoText implements [ciutil.Tasker.InfoText]. -func (t errcheckTask) InfoText() string { return "Lint (errcheck)" } - -// Run implements [ciutil.Tasker.Run]. -func (t errcheckTask) Run() error { - if err := goRun(errcheck, "./..."); err != nil { - return err - } - - return nil -} - -// Post implements [ciutil.Tasker.Post]. -func (t errcheckTask) Post() error { return nil } - -// InfoText implements [ciutil.Tasker.InfoText]. -func (t goImportsTask) InfoText() string { return "Format imports" } - -// Run implements [ciutil.Tasker.Run]. -func (t goImportsTask) Run() error { - args := []string{"-l", "-w", "."} - if err := goRun(goimports, args...); err != nil { - return err - } - - return nil -} - -// Post implements [ciutil.Tasker.Post]. -func (t goImportsTask) Post() error { return nil } - -// InfoText implements [ciutil.Tasker.InfoText]. -func (t govulncheckTask) InfoText() string { return "Vulnerability scan (govulncheck)" } - -// Run implements [ciutil.Tasker.Run]. -func (t govulncheckTask) Run() error { - if err := goRun(govulncheck, "./..."); err != nil { - return err - } - - return nil -} - -// Post implements [ciutil.Tasker.Post]. -func (t govulncheckTask) Post() error { return nil } - -// InfoText implements [ciutil.Tasker.InfoText]. -func (t goTestTask) InfoText() string { return "Test" } - -// Run implements [ciutil.Tasker.Run]. -func (t goTestTask) Run() error { - if err := ciutil.RunCommand([]string{"go", "test", "./..."}); err != nil { - return err - } - - return nil -} - -// Post implements [ciutil.Tasker.Post]. -func (t goTestTask) Post() error { return nil } - -// goRun is a wrapper for "go run" -func goRun(t ciutil.Tool, trailingArgs ...string) error { - args := slices.Concat( - []string{"go", "run", fmt.Sprintf("%s@%s", t.RemotePath, t.Version)}, - trailingArgs, - ) - if err := ciutil.RunCommand(args); err != nil { - return fmt.Errorf("running 'go run': %w", err) - } - - return nil -} diff --git a/internal/ci/go/versions.go b/internal/ci/go/versions.go deleted file mode 100644 index 9890823..0000000 --- a/internal/ci/go/versions.go +++ /dev/null @@ -1,38 +0,0 @@ -package goci - -import ( - "os" - "path/filepath" - - ciutil "github.com/opensourcecorp/oscar/internal/ci/util" -) - -var ( - staticcheck = ciutil.Tool{ - Name: "staticcheck", - RemotePath: "honnef.co/go/tools/cmd/staticcheck", - Version: "2025.1.1", - ConfigFilePath: filepath.Join("./staticcheck.conf"), - } - revive = ciutil.Tool{ - Name: "revive", - RemotePath: "github.com/mgechev/revive", - Version: "v1.11.0", - ConfigFilePath: filepath.Join(os.TempDir(), "revive.toml"), - } - errcheck = ciutil.Tool{ - Name: "errcheck", - RemotePath: "github.com/kisielk/errcheck", - Version: "v1.9.0", - } - goimports = ciutil.Tool{ - Name: "goimports", - RemotePath: "golang.org/x/tools/cmd/goimports", - Version: "v0.35.0", - } - govulncheck = ciutil.Tool{ - Name: "govulncheck", - RemotePath: "golang.org/x/vuln/cmd/govulncheck", - Version: "v1.1.4", - } -) diff --git a/internal/ci/markdown/doc.go b/internal/ci/markdown/doc.go deleted file mode 100644 index 85dae3c..0000000 --- a/internal/ci/markdown/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package markdownci contains logic for running CI tasks for Markdown. -package markdownci diff --git a/internal/ci/markdown/tasks.go b/internal/ci/markdown/tasks.go deleted file mode 100644 index 6a2c3e0..0000000 --- a/internal/ci/markdown/tasks.go +++ /dev/null @@ -1,57 +0,0 @@ -package markdownci - -import ( - "fmt" - "os" - "path/filepath" - - ciconfig "github.com/opensourcecorp/oscar/internal/ci/configfiles" - ciutil "github.com/opensourcecorp/oscar/internal/ci/util" -) - -type ( - markdownlintTask struct{} -) - -var tasks = []ciutil.Tasker{ - markdownlintTask{}, -} - -// Tasks returns the list of CI tasks. -func Tasks(repo ciutil.Repo) []ciutil.Tasker { - if repo.HasMarkdown { - return tasks - } - - return nil -} - -// InfoText implements [ciutil.Tasker.InfoText]. -func (t markdownlintTask) InfoText() string { return "Lint (markdownlint)" } - -// Run implements [ciutil.Tasker.Run]. -func (t markdownlintTask) Run() error { - cfgFileContents, err := ciconfig.Files.ReadFile(filepath.Base(markdownlint.ConfigFilePath)) - if err != nil { - return fmt.Errorf("reading embedded file contents: %w", err) - } - - if err := os.WriteFile(markdownlint.ConfigFilePath, cfgFileContents, 0644); err != nil { - return fmt.Errorf("writing config file: %w", err) - } - - args := []string{ - markdownlint.Name, - "--config", markdownlint.ConfigFilePath, - "**/*.md", - } - - if err := ciutil.RunCommand(args); err != nil { - return err - } - - return nil -} - -// Post implements [ciutil.Tasker.Post]. -func (t markdownlintTask) Post() error { return nil } diff --git a/internal/ci/markdown/versions.go b/internal/ci/markdown/versions.go deleted file mode 100644 index 1bf06d2..0000000 --- a/internal/ci/markdown/versions.go +++ /dev/null @@ -1,15 +0,0 @@ -package markdownci - -import ( - "os" - "path/filepath" - - ciutil "github.com/opensourcecorp/oscar/internal/ci/util" -) - -var ( - markdownlint = ciutil.Tool{ - Name: "markdownlint-cli2", - ConfigFilePath: filepath.Join(os.TempDir(), ".markdownlint-cli2.yaml"), - } -) diff --git a/internal/ci/python/doc.go b/internal/ci/python/doc.go deleted file mode 100644 index 49db153..0000000 --- a/internal/ci/python/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package pythonci contains logic for running CI tasks for Python. -package pythonci diff --git a/internal/ci/python/tasks.go b/internal/ci/python/tasks.go deleted file mode 100644 index 080ff5f..0000000 --- a/internal/ci/python/tasks.go +++ /dev/null @@ -1,135 +0,0 @@ -package pythonci - -import ( - "fmt" - "slices" - - ciutil "github.com/opensourcecorp/oscar/internal/ci/util" -) - -type ( - baseConfigTask struct{} - buildTask struct{} - ruffLintTask struct{} - ruffFormatTask struct{} - pydoclintTask struct{} - mypyTask struct{} -) - -var tasks = []ciutil.Tasker{ - baseConfigTask{}, - buildTask{}, - ruffLintTask{}, - ruffFormatTask{}, - pydoclintTask{}, - mypyTask{}, -} - -// Tasks returns the list of CI tasks. -func Tasks(repo ciutil.Repo) []ciutil.Tasker { - if repo.HasPython { - return tasks - } - - return nil -} - -// InfoText implements [ciutil.Tasker.InfoText]. -func (t baseConfigTask) InfoText() string { return "" } - -// Run implements [ciutil.Tasker.Run]. -func (t baseConfigTask) Run() error { - // ciutil.PlaceConfigFile("pyproject.toml") - - return nil -} - -// Post implements [ciutil.Tasker.Post]. -func (t baseConfigTask) Post() error { return nil } - -// InfoText implements [ciutil.Tasker.InfoText]. -func (t buildTask) InfoText() string { return "Build" } - -// Run implements [ciutil.Tasker.Run]. -func (t buildTask) Run() error { - if err := ciutil.RunCommand([]string{"uv", "build"}); err != nil { - return err - } - - return nil -} - -// Post implements [ciutil.Tasker.Post]. -func (t buildTask) Post() error { return nil } - -// InfoText implements [ciutil.Tasker.InfoText]. -func (t ruffLintTask) InfoText() string { return "Lint (ruff)" } - -// Run implements [ciutil.Tasker.Run]. -func (t ruffLintTask) Run() error { - if err := pyRun(ruffLint, "check", "--fix", "./src"); err != nil { - return err - } - - return nil -} - -// Post implements [ciutil.Tasker.Post]. -func (t ruffLintTask) Post() error { return nil } - -// InfoText implements [ciutil.Tasker.InfoText]. -func (t ruffFormatTask) InfoText() string { return "Format (ruff)" } - -// Run implements [ciutil.Tasker.Run]. -func (t ruffFormatTask) Run() error { - if err := pyRun(ruffFormat, "format", "./src"); err != nil { - return err - } - return nil -} - -// Post implements [ciutil.Tasker.Post]. -func (t ruffFormatTask) Post() error { return nil } - -// InfoText implements [ciutil.Tasker.InfoText]. -func (t pydoclintTask) InfoText() string { return "Lint (pydoclint)" } - -// Run implements [ciutil.Tasker.Run]. -func (t pydoclintTask) Run() error { - if err := pyRun(pydoclint, "./src"); err != nil { - return err - } - - return nil -} - -// Post implements [ciutil.Tasker.Post]. -func (t pydoclintTask) Post() error { return nil } - -// InfoText implements [ciutil.Tasker.InfoText]. -func (t mypyTask) InfoText() string { return "Type-check (mypy)" } - -// Run implements [ciutil.Tasker.Run]. -func (t mypyTask) Run() error { - if err := pyRun(mypy, "./src"); err != nil { - return err - } - - return nil -} - -// Post implements [ciutil.Tasker.Post]. -func (t mypyTask) Post() error { return nil } - -// pyRun is a wrapper for "uvx" -func pyRun(t ciutil.Tool, trailingArgs ...string) error { - args := slices.Concat( - []string{"uvx", fmt.Sprintf("%s@%s", t.Name, t.Version)}, - trailingArgs, - ) - if err := ciutil.RunCommand(args); err != nil { - return fmt.Errorf("running 'uvx': %w", err) - } - - return nil -} diff --git a/internal/ci/python/versions.go b/internal/ci/python/versions.go deleted file mode 100644 index 2a94e0a..0000000 --- a/internal/ci/python/versions.go +++ /dev/null @@ -1,26 +0,0 @@ -package pythonci - -import ( - ciutil "github.com/opensourcecorp/oscar/internal/ci/util" -) - -var ( - // NOTE: even though ruff is used for both linting & formatting, their implementations differ, - // so we need two distinct [ciutil.Tool]s. - ruffLint = ciutil.Tool{ - Name: "ruff", - Version: "0.12.7", - } - ruffFormat = ciutil.Tool{ - Name: "ruff", - Version: "0.12.7", - } - pydoclint = ciutil.Tool{ - Name: "pydoclint", - Version: "0.6.6", - } - mypy = ciutil.Tool{ - Name: "mypy", - Version: "1.17.1", - } -) diff --git a/internal/ci/run.go b/internal/ci/run.go deleted file mode 100644 index 5775a63..0000000 --- a/internal/ci/run.go +++ /dev/null @@ -1,172 +0,0 @@ -package ci - -import ( - "errors" - "fmt" - "os" - "strings" - "time" - - goci "github.com/opensourcecorp/oscar/internal/ci/go" - markdownci "github.com/opensourcecorp/oscar/internal/ci/markdown" - pythonci "github.com/opensourcecorp/oscar/internal/ci/python" - shellci "github.com/opensourcecorp/oscar/internal/ci/shell" - ciutil "github.com/opensourcecorp/oscar/internal/ci/util" - "github.com/opensourcecorp/oscar/internal/consts" - igit "github.com/opensourcecorp/oscar/internal/git" - iprint "github.com/opensourcecorp/oscar/internal/print" -) - -// TaskMap is a less-verbose type alias for mapping language names to function signatures that -// return a language's tasks. -type TaskMap map[string][]ciutil.Tasker - -// GetCITaskMap assembles the overall list of CI tasks, keyed by their language/tooling name -func GetCITaskMap() (TaskMap, error) { - repo, err := ciutil.GetRepoComposition() - if err != nil { - return nil, fmt.Errorf("getting repo composition: %w", err) - } - - out := make(TaskMap, 0) - for langName, getTasksFunc := range map[string]func(ciutil.Repo) []ciutil.Tasker{ - "Go": goci.Tasks, - "Python": pythonci.Tasks, - "Shell": shellci.Tasks, - "Markdown": markdownci.Tasks, - } { - tasks := getTasksFunc(repo) - if len(tasks) > 0 { - out[langName] = tasks - } - } - - if len(out) > 0 { - fmt.Print(repo.String()) - iprint.Debugf("GetCITasks output: %+v\n", out) - } - - return out, nil -} - -// Run defines the behavior for running all CI tasks for the repository. -func Run() (err error) { - runStartTime := time.Now() - - // Handle system init - if err := ciutil.InitSystem(); err != nil { - return fmt.Errorf("initializing system: %w", err) - } - // The mise config that oscar uses is written during init, so be sure to defer its removal here - defer func() { - if rmErr := os.Remove(consts.MiseConfigFileName); rmErr != nil { - err = errors.Join(err, fmt.Errorf("removing mise config file: %w", rmErr)) - } - }() - - var ( - // Vars for determining text padding in output banners - longestLanguageNameLength int - longestInfoTextLength int - ) - - // All the CI tasks that will be looped over. Will also print a summary of discovered file - // types. - ciTaskMap, err := GetCITaskMap() - if err != nil { - return fmt.Errorf("getting CI tasks: %w", err) - } - - // Log padding setup - for lang, tasks := range ciTaskMap { - longestLanguageNameLength = max(longestLanguageNameLength, len(lang)) - for _, t := range tasks { - longestInfoTextLength = max(longestInfoTextLength, len(t.InfoText())) - } - } - - iprint.Debugf("longestLanguageNameLength: %d\n", longestLanguageNameLength) - iprint.Debugf("longestInfoTextLength: %d\n", longestInfoTextLength) - - // For tracking any changes to Git status etc. after each Task runs - git, err := igit.New() - if err != nil { - return fmt.Errorf("internal error: %w", err) - } - - // Keeps track of all task failures - failures := make([]string, 0) - for lang, tasks := range ciTaskMap { - langNameBannerPadding := strings.Repeat("=", longestLanguageNameLength-len(lang)/2) - fmt.Printf( - "============%s %s %s============\n", - langNameBannerPadding, lang, langNameBannerPadding, - ) - - for _, t := range tasks { - // NOTE: if no InfoText() method is provided, it's probably a lang-wide init func, so skip it - if t.InfoText() == "" { - continue - } - - taskStartTime := time.Now() - - taskBannerPadding := strings.Repeat(".", longestInfoTextLength-len(t.InfoText())) - // NOTE: no trailing newline on purpose - fmt.Printf("> %s %s............", t.InfoText(), taskBannerPadding) - - // NOTE: this error is checked later, when we can check the Run, Post, and git-diff - // potential errors together - var runErr error - runErr = errors.Join(runErr, t.Run()) - runErr = errors.Join(runErr, t.Post()) - - if err := git.Update(); err != nil { - return fmt.Errorf("internal error: %w", err) - } - gitStatusHasChanged, err := git.StatusHasChanged() - if err != nil { - return fmt.Errorf("internal error: %w", err) - } - - if runErr != nil || gitStatusHasChanged { - iprint.Errorf("FAILED! (%s)\n", ciutil.RunDurationString(taskStartTime)) - iprint.Errorf("\n") - - if runErr != nil { - iprint.Errorf("%v\n", runErr) - } - - if gitStatusHasChanged { - iprint.Errorf("Files ~CHANGED~ during run: %+v\n", git.CurrentStatus.Diff) - iprint.Errorf("Files +CREATED+ during run: %+v\n", git.CurrentStatus.UntrackedFiles) - iprint.Errorf("\n") - } - - failures = append(failures, fmt.Sprintf("%s :: %s", lang, t.InfoText())) - - // Also need to reset the baseline status - git, err = igit.New() - if err != nil { - return fmt.Errorf("internal error: %w", err) - } - } else { - fmt.Printf("PASSED (%s)\n", ciutil.RunDurationString(taskStartTime)) - } - } - } - - if len(failures) > 0 { - iprint.Errorf("\n================================================================\n") - iprint.Errorf("The following checks failed and/or caused a git diff: (%s)\n", ciutil.RunDurationString(runStartTime)) - for _, f := range failures { - iprint.Errorf("- %s\n", f) - } - iprint.Errorf("================================================================\n\n") - return errors.New("one or more CI checks failed") - } - - fmt.Printf("All checks passed! (%s)\n", ciutil.RunDurationString(runStartTime)) - - return err -} diff --git a/internal/ci/shell/doc.go b/internal/ci/shell/doc.go deleted file mode 100644 index cf7eebe..0000000 --- a/internal/ci/shell/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package shellci contains logic for running CI tasks for Shell languages. -package shellci diff --git a/internal/ci/shell/tasks.go b/internal/ci/shell/tasks.go deleted file mode 100644 index b214739..0000000 --- a/internal/ci/shell/tasks.go +++ /dev/null @@ -1,89 +0,0 @@ -package shellci - -import ( - "fmt" - - ciutil "github.com/opensourcecorp/oscar/internal/ci/util" -) - -type ( - shellcheckTask struct{} - shfmtTask struct{} - batsTask struct{} -) - -var tasks = []ciutil.Tasker{ - shellcheckTask{}, - shfmtTask{}, - batsTask{}, -} - -// Tasks returns the list of CI tasks. -func Tasks(repo ciutil.Repo) []ciutil.Tasker { - if repo.HasShell { - return tasks - } - - return nil -} - -// InfoText implements [ciutil.Tasker.InfoText]. -func (t shellcheckTask) InfoText() string { return "Lint (shellcheck)" } - -// Run implements [ciutil.Tasker.Run]. -func (t shellcheckTask) Run() error { - args := []string{"bash", "-c", fmt.Sprintf(` - shopt -s globstar - ls **/*.sh || exit 0 - %s **/*.sh - `, shellcheck.Name)} - if err := ciutil.RunCommand(args); err != nil { - return err - } - - return nil -} - -// Post implements [ciutil.Tasker.Post]. -func (t shellcheckTask) Post() error { return nil } - -// InfoText implements [ciutil.Tasker.InfoText]. -func (t shfmtTask) InfoText() string { return "Format (shfmt)" } - -// Run implements [ciutil.Tasker.Run]. -func (t shfmtTask) Run() error { - args := []string{"bash", "-c", fmt.Sprintf(` - shopt -s globstar - ls **/*.sh || exit 0 - %s **/*.sh - `, shfmt.Name)} - if err := ciutil.RunCommand(args); err != nil { - return err - } - - return nil -} - -// Post implements [ciutil.Tasker.Post]. -func (t shfmtTask) Post() error { return nil } - -// InfoText implements [ciutil.Tasker.InfoText]. -func (t batsTask) InfoText() string { return "Test (bats)" } - -// Run implements [ciutil.Tasker.Run]. -func (t batsTask) Run() error { - args := []string{"bash", "-c", fmt.Sprintf(` - shopt -s globstar - # Don't run if no bats files found, otherwise it will error out - ls **/*.bats || exit 0 - %s **/*.bats - `, bats.Name)} - if err := ciutil.RunCommand(args); err != nil { - return err - } - - return nil -} - -// Post implements [ciutil.Tasker.Post]. -func (t batsTask) Post() error { return nil } diff --git a/internal/ci/shell/versions.go b/internal/ci/shell/versions.go deleted file mode 100644 index f3ccb61..0000000 --- a/internal/ci/shell/versions.go +++ /dev/null @@ -1,22 +0,0 @@ -package shellci - -import ( - ciutil "github.com/opensourcecorp/oscar/internal/ci/util" -) - -var ( - shellcheck = ciutil.Tool{ - Name: "shellcheck", - Version: "v0.11.0", - } - shfmt = ciutil.Tool{ - Name: "shfmt", - Version: "v3.12.0", - } - bats = ciutil.Tool{ - Name: "bats", - // NOTE: bats just gets cloned then installed with its install script - RemotePath: "https://github.com/bats-core/bats-core.git", - Version: "v1.12.0", - } -) diff --git a/internal/ci/util/doc.go b/internal/ci/util/doc.go deleted file mode 100644 index 65a6afd..0000000 --- a/internal/ci/util/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package ciutil provides core types, helper functions, etc. for running CI tasks. -package ciutil diff --git a/internal/ci/util/types.go b/internal/ci/util/types.go deleted file mode 100644 index 05434d8..0000000 --- a/internal/ci/util/types.go +++ /dev/null @@ -1,61 +0,0 @@ -package ciutil - -// Tasker defines the method set for working with metadata for a given CI Task. -type Tasker interface { - // InfoText should return a human-readable display string that describes the task, e.g. "Run - // tests". If this is unset, then its banner will not show in the CI log output at all (which - // may be desirable) in the case of implementers of [Tasker.Init]) - InfoText() string - // Run should perform the actual task's actions. - Run() error - // Post should perform any post-run actions for the task, if necessary. - Post() error -} - -// Repo stores information about the contents of the repository being ran against. -type Repo struct { - HasGo bool - HasPython bool - HasShell bool - HasMarkdown bool -} - -// A Tool is a helper struct used to help other types implementing [Tasker] pass around their tool -// versioning/installation information. -type Tool struct { - // The tool's name, used as an identifier. May also be the tool's invocable command, in which - // case it can be interpolated as such. - Name string - // The installable path for the tool, like a URL. Can also be a format string, e.g. with - // placeholders for platform-specific strings. - RemotePath string - // The version of the tool. - Version string - // The path to the tool's config file, if it has one to use. - ConfigFilePath string -} - -// String implements the [fmt.Stringer] interface. -func (repo Repo) String() string { - var out string - - out += "The following file types were found in this repo, and CI checks will be run against them:\n" - - if repo.HasGo { - out += "- Go\n" - } - if repo.HasPython { - out += "- Python\n" - } - if repo.HasShell { - out += "- Shell (sh, bash, etc.)\n" - } - if repo.HasMarkdown { - out += "- Markdown\n" - } - - // One more newline for padding - out += "\n" - - return out -} diff --git a/internal/cli/root.go b/internal/cli/root.go index 152a96f..8b5d89e 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -5,12 +5,12 @@ import ( "errors" "fmt" "os" - "strings" - "github.com/opensourcecorp/oscar" - "github.com/opensourcecorp/oscar/internal/ci" "github.com/opensourcecorp/oscar/internal/consts" + "github.com/opensourcecorp/oscar/internal/oscarcfg" iprint "github.com/opensourcecorp/oscar/internal/print" + "github.com/opensourcecorp/oscar/internal/tasks/ci" + "github.com/opensourcecorp/oscar/internal/tasks/delivery" "github.com/urfave/cli/v3" ) @@ -20,14 +20,22 @@ const ( debugFlagName = "debug" ciCommandName = "ci" + + deliverCommandName = "deliver" ) // NewRootCmd defines & returns the CLI command used as oscar's entrypoint. func NewRootCmd() *cli.Command { + version, err := getVersion() + if err != nil { + iprint.Errorf("determining your version: %v\n", err) + os.Exit(1) + } + cmd := &cli.Command{ Name: rootCmdName, Usage: "The OpenSourceCorp Automation Runner", - Version: getVersion(), + Version: version, Action: rootAction, Flags: []cli.Flag{ &cli.BoolFlag{ @@ -42,6 +50,11 @@ func NewRootCmd() *cli.Command { Usage: "Runs CI tasks", Action: ciAction, }, + { + Name: deliverCommandName, + Usage: "Runs Delivery tasks", + Action: deliverAction, + }, }, } @@ -56,21 +69,13 @@ func maybeSetDebug(cmd *cli.Command) { } // getVersion retrieves the version of the codebase. -func getVersion() string { - contents, err := oscar.Files.ReadFile("VERSION") +func getVersion() (string, error) { + cfg, err := oscarcfg.Get() if err != nil { - panic(fmt.Sprintf("Internal error trying to read VERSION file: %v", err)) + return "", fmt.Errorf("reading oscar config file: %w", err) } - splits := strings.Split(string(contents), "\n") - var version string - for _, line := range splits { - if !strings.HasPrefix(line, "#") { - version = line - break - } - } - return version + return cfg.Version, nil } // rootAction defines the logic for oscar's root command. @@ -78,17 +83,30 @@ func rootAction(_ context.Context, cmd *cli.Command) error { maybeSetDebug(cmd) iprint.Debugf("oscar root command\n") _ = cli.ShowAppHelp(cmd) - return errors.New("\nERROR: oscar requires a subcommand") + return errors.New("\nERROR: oscar requires a valid subcommand") } -// rootAction defines the logic for oscar's ci subcommand. -func ciAction(_ context.Context, cmd *cli.Command) error { +// ciAction defines the logic for oscar's ci subcommand. +func ciAction(ctx context.Context, cmd *cli.Command) error { maybeSetDebug(cmd) iprint.Banner() iprint.Debugf("oscar ci subcommand\n") - if err := ci.Run(); err != nil { - return fmt.Errorf("running CI: %w", err) + if err := ci.Run(ctx); err != nil { + return fmt.Errorf("running CI tasks: %w", err) + } + + return nil +} + +// deliverAction defines the logic for oscar's deliver subcommand. +func deliverAction(ctx context.Context, cmd *cli.Command) error { + maybeSetDebug(cmd) + iprint.Banner() + iprint.Debugf("oscar deliver subcommand\n") + + if err := delivery.Run(ctx); err != nil { + return fmt.Errorf("running Delivery tasks: %w", err) } return nil diff --git a/internal/consts/consts.go b/internal/consts/consts.go index feed930..7934546 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -13,7 +13,10 @@ const ( // MiseVersion is the default version of mise to install if not present. Can be overridden via // the `MISE_VERSION` env var, which is checked elsewhere. - MiseVersion = "v2025.8.21" + MiseVersion = "v2025.9.10" + + // DefaultOscarCfgFileName is the default basename of oscar's config file. + DefaultOscarCfgFileName = "oscar.yaml" ) var ( diff --git a/internal/generated/opensourcecorp/oscar/config/v1/config.pb.go b/internal/generated/opensourcecorp/oscar/config/v1/config.pb.go new file mode 100644 index 0000000..252d1c4 --- /dev/null +++ b/internal/generated/opensourcecorp/oscar/config/v1/config.pb.go @@ -0,0 +1,340 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.8 +// protoc (unknown) +// source: opensourcecorp/oscar/config/v1/config.proto + +package oscarcfgpbv1 + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Config defines the top-level structure of oscar's config file. +type Config struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Version is the version string for the codebase. Must be a valid Semantic Version string. + // + // Example: "1.0.0" + Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` + // Deliverables is the collection of possible deliverable artifacts. + Deliverables *Deliverables `protobuf:"bytes,2,opt,name=deliverables,proto3" json:"deliverables,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Config) Reset() { + *x = Config{} + mi := &file_opensourcecorp_oscar_config_v1_config_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Config) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Config) ProtoMessage() {} + +func (x *Config) ProtoReflect() protoreflect.Message { + mi := &file_opensourcecorp_oscar_config_v1_config_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Config.ProtoReflect.Descriptor instead. +func (*Config) Descriptor() ([]byte, []int) { + return file_opensourcecorp_oscar_config_v1_config_proto_rawDescGZIP(), []int{0} +} + +func (x *Config) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *Config) GetDeliverables() *Deliverables { + if x != nil { + return x.Deliverables + } + return nil +} + +// Deliverables contains a field for each possible deliverable. +type Deliverables struct { + state protoimpl.MessageState `protogen:"open.v1"` + // See [GoGitHubRelease]. + GoGithubRelease *GoGitHubRelease `protobuf:"bytes,1,opt,name=go_github_release,json=goGithubRelease,proto3" json:"go_github_release,omitempty"` + // See [ContainerImage]. + ContainerImage *ContainerImage `protobuf:"bytes,2,opt,name=container_image,json=containerImage,proto3" json:"container_image,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Deliverables) Reset() { + *x = Deliverables{} + mi := &file_opensourcecorp_oscar_config_v1_config_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Deliverables) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Deliverables) ProtoMessage() {} + +func (x *Deliverables) ProtoReflect() protoreflect.Message { + mi := &file_opensourcecorp_oscar_config_v1_config_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Deliverables.ProtoReflect.Descriptor instead. +func (*Deliverables) Descriptor() ([]byte, []int) { + return file_opensourcecorp_oscar_config_v1_config_proto_rawDescGZIP(), []int{1} +} + +func (x *Deliverables) GetGoGithubRelease() *GoGitHubRelease { + if x != nil { + return x.GoGithubRelease + } + return nil +} + +func (x *Deliverables) GetContainerImage() *ContainerImage { + if x != nil { + return x.ContainerImage + } + return nil +} + +// GoGitHubRelease defines the arguments necessary to create GitHub Releases for Go binaries. +type GoGitHubRelease struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The filepaths to the "main" packages to be built. + // + // Example: - "./cmd/oscar" + BuildSources []string `protobuf:"bytes,1,rep,name=build_sources,json=buildSources,proto3" json:"build_sources,omitempty"` + // Optionally flags whether the Release should be left in Draft state at create-time. This can + // be useful to set if you want to review the Release contents before actually publishing. + // + // Example: false + Draft bool `protobuf:"varint,2,opt,name=draft,proto3" json:"draft,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GoGitHubRelease) Reset() { + *x = GoGitHubRelease{} + mi := &file_opensourcecorp_oscar_config_v1_config_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GoGitHubRelease) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GoGitHubRelease) ProtoMessage() {} + +func (x *GoGitHubRelease) ProtoReflect() protoreflect.Message { + mi := &file_opensourcecorp_oscar_config_v1_config_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GoGitHubRelease.ProtoReflect.Descriptor instead. +func (*GoGitHubRelease) Descriptor() ([]byte, []int) { + return file_opensourcecorp_oscar_config_v1_config_proto_rawDescGZIP(), []int{2} +} + +func (x *GoGitHubRelease) GetBuildSources() []string { + if x != nil { + return x.BuildSources + } + return nil +} + +func (x *GoGitHubRelease) GetDraft() bool { + if x != nil { + return x.Draft + } + return false +} + +// ContainerImage defines the arguments necessary to build & push container image artifacts. +type ContainerImage struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The target registry provider domain. + // + // Example: "ghcr.io" + Registry string `protobuf:"bytes,1,opt,name=registry,proto3" json:"registry,omitempty"` + // The target OCI namespace. + // + // Example: "opensourcecorp" + Namespace string `protobuf:"bytes,2,opt,name=namespace,proto3" json:"namespace,omitempty"` + // The target OCI image name. May contain as many subpaths to the actual image artifact as + // necessary based on the registry, e.g. "my-repo/my-image-group/my-image". + // + // Example: "oscar" + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ContainerImage) Reset() { + *x = ContainerImage{} + mi := &file_opensourcecorp_oscar_config_v1_config_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ContainerImage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ContainerImage) ProtoMessage() {} + +func (x *ContainerImage) ProtoReflect() protoreflect.Message { + mi := &file_opensourcecorp_oscar_config_v1_config_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ContainerImage.ProtoReflect.Descriptor instead. +func (*ContainerImage) Descriptor() ([]byte, []int) { + return file_opensourcecorp_oscar_config_v1_config_proto_rawDescGZIP(), []int{3} +} + +func (x *ContainerImage) GetRegistry() string { + if x != nil { + return x.Registry + } + return "" +} + +func (x *ContainerImage) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + +func (x *ContainerImage) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +var File_opensourcecorp_oscar_config_v1_config_proto protoreflect.FileDescriptor + +const file_opensourcecorp_oscar_config_v1_config_proto_rawDesc = "" + + "\n" + + "+opensourcecorp/oscar/config/v1/config.proto\x12\x1eopensourcecorp.oscar.config.v1\x1a\x1bbuf/validate/validate.proto\"\xb6\x01\n" + + "\x06Config\x12Z\n" + + "\aversion\x18\x01 \x01(\tB@\xbaH=r;29^[0-9]+\\.[0-9]+\\.[0-9]+(-[a-zA-Z0-9]+)?(\\+[a-zA-Z0-9]+)?$R\aversion\x12P\n" + + "\fdeliverables\x18\x02 \x01(\v2,.opensourcecorp.oscar.config.v1.DeliverablesR\fdeliverables\"\xc4\x01\n" + + "\fDeliverables\x12[\n" + + "\x11go_github_release\x18\x01 \x01(\v2/.opensourcecorp.oscar.config.v1.GoGitHubReleaseR\x0fgoGithubRelease\x12W\n" + + "\x0fcontainer_image\x18\x02 \x01(\v2..opensourcecorp.oscar.config.v1.ContainerImageR\x0econtainerImage\"T\n" + + "\x0fGoGitHubRelease\x12+\n" + + "\rbuild_sources\x18\x01 \x03(\tB\x06\xbaH\x03\xc8\x01\x01R\fbuildSources\x12\x14\n" + + "\x05draft\x18\x02 \x01(\bR\x05draft\"v\n" + + "\x0eContainerImage\x12\"\n" + + "\bregistry\x18\x01 \x01(\tB\x06\xbaH\x03\xc8\x01\x01R\bregistry\x12$\n" + + "\tnamespace\x18\x02 \x01(\tB\x06\xbaH\x03\xc8\x01\x01R\tnamespace\x12\x1a\n" + + "\x04name\x18\x03 \x01(\tB\x06\xbaH\x03\xc8\x01\x01R\x04nameB`Z^github.com/opensourcecorp/oscar/internal/generated/opensourcecorp/oscar/config/v1;oscarcfgpbv1b\x06proto3" + +var ( + file_opensourcecorp_oscar_config_v1_config_proto_rawDescOnce sync.Once + file_opensourcecorp_oscar_config_v1_config_proto_rawDescData []byte +) + +func file_opensourcecorp_oscar_config_v1_config_proto_rawDescGZIP() []byte { + file_opensourcecorp_oscar_config_v1_config_proto_rawDescOnce.Do(func() { + file_opensourcecorp_oscar_config_v1_config_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_opensourcecorp_oscar_config_v1_config_proto_rawDesc), len(file_opensourcecorp_oscar_config_v1_config_proto_rawDesc))) + }) + return file_opensourcecorp_oscar_config_v1_config_proto_rawDescData +} + +var file_opensourcecorp_oscar_config_v1_config_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_opensourcecorp_oscar_config_v1_config_proto_goTypes = []any{ + (*Config)(nil), // 0: opensourcecorp.oscar.config.v1.Config + (*Deliverables)(nil), // 1: opensourcecorp.oscar.config.v1.Deliverables + (*GoGitHubRelease)(nil), // 2: opensourcecorp.oscar.config.v1.GoGitHubRelease + (*ContainerImage)(nil), // 3: opensourcecorp.oscar.config.v1.ContainerImage +} +var file_opensourcecorp_oscar_config_v1_config_proto_depIdxs = []int32{ + 1, // 0: opensourcecorp.oscar.config.v1.Config.deliverables:type_name -> opensourcecorp.oscar.config.v1.Deliverables + 2, // 1: opensourcecorp.oscar.config.v1.Deliverables.go_github_release:type_name -> opensourcecorp.oscar.config.v1.GoGitHubRelease + 3, // 2: opensourcecorp.oscar.config.v1.Deliverables.container_image:type_name -> opensourcecorp.oscar.config.v1.ContainerImage + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_opensourcecorp_oscar_config_v1_config_proto_init() } +func file_opensourcecorp_oscar_config_v1_config_proto_init() { + if File_opensourcecorp_oscar_config_v1_config_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_opensourcecorp_oscar_config_v1_config_proto_rawDesc), len(file_opensourcecorp_oscar_config_v1_config_proto_rawDesc)), + NumEnums: 0, + NumMessages: 4, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_opensourcecorp_oscar_config_v1_config_proto_goTypes, + DependencyIndexes: file_opensourcecorp_oscar_config_v1_config_proto_depIdxs, + MessageInfos: file_opensourcecorp_oscar_config_v1_config_proto_msgTypes, + }.Build() + File_opensourcecorp_oscar_config_v1_config_proto = out.File + file_opensourcecorp_oscar_config_v1_config_proto_goTypes = nil + file_opensourcecorp_oscar_config_v1_config_proto_depIdxs = nil +} diff --git a/internal/git/ci.go b/internal/git/ci.go new file mode 100644 index 0000000..251d054 --- /dev/null +++ b/internal/git/ci.go @@ -0,0 +1,122 @@ +package igit + +import ( + "context" + "fmt" + "regexp" + "slices" + "strings" + + iprint "github.com/opensourcecorp/oscar/internal/print" +) + +// CI defines metadata & behavior for CI tasks. +type CI struct { + // BaselineStatus is used to check against when running CI checks, so that each CI task can + // see if it introduced changes. + BaselineStatus Status + // CurrentStatus is the latest-available Git status, which may differ from the baseline. + CurrentStatus Status +} + +// NewForCI returns Git information for CI tasks. +func NewForCI(ctx context.Context) (*CI, error) { + status, err := getRawStatus(ctx) + if err != nil { + return nil, err + } + + return &CI{ + BaselineStatus: status, + }, nil +} + +// Update recalculates various Git metadata, respecting any existing baseline values set in [NewForCI]. +func (g *CI) Update(ctx context.Context) error { + status, err := getRawStatus(ctx) + if err != nil { + return fmt.Errorf("getting Git status: %w", err) + } + + untrackedFiles := make([]string, 0) + diff := make([]string, 0) + + for _, line := range status.Diff { + if !slices.Contains(g.BaselineStatus.Diff, line) { + filename := regexp.MustCompile(`^ ?[A-Z]+ `).ReplaceAllString(line, "") + diff = append(diff, filename) + } + } + + for _, line := range status.UntrackedFiles { + if !slices.Contains(g.BaselineStatus.UntrackedFiles, line) { + filename := strings.ReplaceAll(line, "?? ", "") + untrackedFiles = append(diff, filename) + } + } + + g.CurrentStatus = Status{ + Diff: diff, + UntrackedFiles: untrackedFiles, + } + + return nil +} + +// StatusHasChanged informs the caller of whether or not the [Status] now differs from the baseline. +func (g *CI) StatusHasChanged(ctx context.Context) (bool, error) { + if err := g.updateStatus(ctx); err != nil { + return false, err + } + + iprint.Debugf("len(g.CurrentStatus.Diff) = %d\n", len(g.CurrentStatus.Diff)) + iprint.Debugf("len(g.BaselineStatusForCI.Diff) = %d\n", len(g.BaselineStatus.Diff)) + iprint.Debugf("len(g.CurrentStatus.UntrackedFiles) = %d\n", len(g.CurrentStatus.UntrackedFiles)) + iprint.Debugf("len(g.BaselineStatusForCI.UntrackedFiles) = %d\n", len(g.BaselineStatus.UntrackedFiles)) + + statusChanged := (len(g.CurrentStatus.Diff)+len(g.BaselineStatus.Diff)) != len(g.BaselineStatus.Diff) || + (len(g.CurrentStatus.UntrackedFiles)+len(g.BaselineStatus.UntrackedFiles)) != len(g.BaselineStatus.UntrackedFiles) + + iprint.Debugf("statusChanged: %v\n", statusChanged) + + return statusChanged, nil +} + +// updateStatus updates the tracked Git status so that it can be compared against the baseline. +func (g *CI) updateStatus(ctx context.Context) error { + // So any future debug logs have a line break in them + iprint.Debugf("\n") + + iprint.Debugf("OLD git: %+v\n", g) + + status, err := getRawStatus(ctx) + if err != nil { + return fmt.Errorf("getting Git status: %w", err) + } + + diff := make([]string, 0) + untrackedFiles := make([]string, 0) + + for _, line := range status.Diff { + filename := regexp.MustCompile(`^( +)?[A-Z]+ +`).ReplaceAllString(line, "") + if !slices.Contains(g.BaselineStatus.Diff, filename) { + diff = append(diff, filename) + } + } + + for _, line := range status.UntrackedFiles { + filename := strings.ReplaceAll(line, "?? ", "") + if !slices.Contains(g.BaselineStatus.UntrackedFiles, filename) { + untrackedFiles = append(diff, filename) + } + } + + g.CurrentStatus = Status{ + Diff: diff, + UntrackedFiles: untrackedFiles, + } + + iprint.Debugf("NEW git: %+v\n", g) + + return nil +} diff --git a/internal/git/doc.go b/internal/git/doc.go index 8c6fcf5..cdc1b10 100644 --- a/internal/git/doc.go +++ b/internal/git/doc.go @@ -1,2 +1,2 @@ -// Package igit defines logic for interacting with Git on the host. +// Package igit provides interoperability with Git. package igit diff --git a/internal/git/git.go b/internal/git/git.go index 440023d..a479265 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -1,98 +1,113 @@ package igit import ( + "context" "fmt" - "os/exec" + "reflect" "regexp" - "slices" "strings" iprint "github.com/opensourcecorp/oscar/internal/print" + taskutil "github.com/opensourcecorp/oscar/internal/tasks/util" ) -// Git defines metadata & behavior for Git interactions. +// Git holds metadata about the current state of the Git repository. type Git struct { - // BaselineStatusForCI is used to check against when running CI checks, so that each CI task can - // see if it introduced changes. - BaselineStatusForCI Status - // CurrentStatus is the latest-available Git status, which may differ from the baseline. - CurrentStatus Status + // The root directory of the repository on the host. + Root string + // The current branch name. + Branch string + // The latest tag available in the repo. + LatestTag string + // The latest commit on the current branch. + LatestCommit string + // Whether or not the working directory has uncommitted changes. + IsDirty bool } // Status holds various pieces of information about Git status. type Status struct { - Diff []string + // The list of modified files. + Diff []string + // The list of untracked files. UntrackedFiles []string } -// New returns a snapshot of Git information available at call-time. -func New() (*Git, error) { - status, err := getRawStatus() +// New returns a populated [Git]. +func New(ctx context.Context) (*Git, error) { + root, err := taskutil.RunCommand(ctx, []string{"git", "rev-parse", "--show-toplevel"}) if err != nil { return nil, err } + iprint.Debugf("Git root on host: '%s'\n", root) - return &Git{ - BaselineStatusForCI: status, - }, nil -} + branch, err := taskutil.RunCommand(ctx, []string{"git", "rev-parse", "--abbrev-ref", "HEAD"}) + if err != nil { + return nil, err + } + iprint.Debugf("Git branch: '%s'\n", branch) -// Update recalculates various Git metadata, respecting any existing baseline values set in [New]. -func (g *Git) Update() error { - status, err := getRawStatus() + latestTag, err := taskutil.RunCommand(ctx, []string{"bash", "-c", "git tag --list | tail -n1"}) if err != nil { - return fmt.Errorf("getting Git status: %w", err) + return nil, err } + iprint.Debugf("latest Git tag: '%s'\n", latestTag) - untrackedFiles := make([]string, 0) - diff := make([]string, 0) + latestCommit, err := taskutil.RunCommand(ctx, []string{"git", "rev-parse", "--short=8", "HEAD"}) + if err != nil { + return nil, err + } + iprint.Debugf("latest Git commit: '%s'\n", latestCommit) - for _, line := range status.Diff { - if !slices.Contains(g.BaselineStatusForCI.Diff, line) { - filename := regexp.MustCompile(`^ [A-Z] `).ReplaceAllString(line, "") - diff = append(diff, filename) - } + gitStatus, err := getRawStatus(ctx) + if err != nil { + return nil, fmt.Errorf("getting Git status: %w", err) } - for _, line := range status.UntrackedFiles { - if !slices.Contains(g.BaselineStatusForCI.UntrackedFiles, line) { - filename := strings.ReplaceAll(line, "?? ", "") - untrackedFiles = append(diff, filename) - } + var isDirty bool + if len(gitStatus.Diff) > 0 || len(gitStatus.UntrackedFiles) > 0 { + isDirty = true } - g.CurrentStatus = Status{ - Diff: diff, - UntrackedFiles: untrackedFiles, + out := Git{ + Root: root, + Branch: branch, + LatestTag: latestTag, + LatestCommit: latestCommit, + IsDirty: isDirty, } + iprint.Debugf("Git: %+v\n", out) - return nil + return &out, nil } -// StatusHasChanged informs the caller of whether or not the [Status] now differs from the baseline. -func (g *Git) StatusHasChanged() (bool, error) { - if err := g.updateStatus(); err != nil { - return false, err - } +// SanitizedBranch returns the current branch name, sanitized for various systems that allow for a +// smaller charset (e.g. container image tags). +func (g *Git) SanitizedBranch() string { + return regexp.MustCompile(`[_/]`).ReplaceAllString(g.Branch, "-") +} - iprint.Debugf("len(g.CurrentStatus.Diff) = %d\n", len(g.CurrentStatus.Diff)) - iprint.Debugf("len(g.BaselineStatusForCI.Diff) = %d\n", len(g.BaselineStatusForCI.Diff)) - iprint.Debugf("len(g.CurrentStatus.UntrackedFiles) = %d\n", len(g.CurrentStatus.UntrackedFiles)) - iprint.Debugf("len(g.BaselineStatusForCI.UntrackedFiles) = %d\n", len(g.BaselineStatusForCI.UntrackedFiles)) +// String implements [fmt.Stringer]. +func (g *Git) String() string { + out := "Current Git information:\n" - statusChanged := (len(g.CurrentStatus.Diff)+len(g.BaselineStatusForCI.Diff)) != len(g.BaselineStatusForCI.Diff) || - (len(g.CurrentStatus.UntrackedFiles)+len(g.BaselineStatusForCI.UntrackedFiles)) != len(g.BaselineStatusForCI.UntrackedFiles) + t := reflect.TypeOf(*g) + v := reflect.ValueOf(*g) + for i := range v.NumField() { + field := t.Field(i) + value := v.Field(i) + out += fmt.Sprintf("- %s: %v\n", field.Name, value) + } - iprint.Debugf("statusChanged: %v\n", statusChanged) + out += "\n" - return statusChanged, nil + return out } // getRawStatus returns a slightly-modified "git status" output, so that calling tools can parse it // more easily. -func getRawStatus() (Status, error) { - cmd := exec.Command("git", "status", "--porcelain") - outputBytes, err := cmd.CombinedOutput() +func getRawStatus(ctx context.Context) (Status, error) { + outputBytes, err := taskutil.RunCommand(ctx, []string{"git", "status", "--porcelain"}) if err != nil { return Status{}, fmt.Errorf("getting git status output: %w", err) } @@ -120,42 +135,3 @@ func getRawStatus() (Status, error) { UntrackedFiles: untrackedFiles, }, nil } - -// updateStatus updates the tracked Git status so that it can be compared against the baseline. -func (g *Git) updateStatus() error { - // So any future debug logs have a line break in them - iprint.Debugf("\n") - - iprint.Debugf("OLD git: %+v\n", g) - - status, err := getRawStatus() - if err != nil { - return fmt.Errorf("getting Git status: %w", err) - } - - diff := make([]string, 0) - untrackedFiles := make([]string, 0) - - for _, line := range status.Diff { - filename := regexp.MustCompile(`^( +)?[A-Z]+ +`).ReplaceAllString(line, "") - if !slices.Contains(g.BaselineStatusForCI.Diff, filename) { - diff = append(diff, filename) - } - } - - for _, line := range status.UntrackedFiles { - filename := strings.ReplaceAll(line, "?? ", "") - if !slices.Contains(g.BaselineStatusForCI.UntrackedFiles, filename) { - untrackedFiles = append(diff, filename) - } - } - - g.CurrentStatus = Status{ - Diff: diff, - UntrackedFiles: untrackedFiles, - } - - iprint.Debugf("NEW git: %+v\n", g) - - return nil -} diff --git a/internal/git/git_test.go b/internal/git/git_test.go new file mode 100644 index 0000000..c7cd3e1 --- /dev/null +++ b/internal/git/git_test.go @@ -0,0 +1,39 @@ +package igit + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSanitizedBranch(t *testing.T) { + want := "feature-wip" + + tt := []struct { + Name string + Branch string + }{ + { + Name: "no replacement", + Branch: "feature-wip", + }, + { + Name: "replace slashes", + Branch: "feature/wip", + }, + { + Name: "replace underscores", + Branch: "feature_wip", + }, + } + + for _, s := range tt { + t.Run(s.Name, func(t *testing.T) { + g := &Git{ + Branch: s.Branch, + } + got := g.SanitizedBranch() + assert.Equal(t, want, got) + }) + } +} diff --git a/internal/hostinfo/doc.go b/internal/hostinfo/doc.go new file mode 100644 index 0000000..df9de60 --- /dev/null +++ b/internal/hostinfo/doc.go @@ -0,0 +1,2 @@ +// Package hostinfo provides functionality for getting data about the host system. +package hostinfo diff --git a/internal/ci/util/hostinfo.go b/internal/hostinfo/hostinfo.go similarity index 79% rename from internal/ci/util/hostinfo.go rename to internal/hostinfo/hostinfo.go index 289ddf1..56c9d8d 100644 --- a/internal/ci/util/hostinfo.go +++ b/internal/hostinfo/hostinfo.go @@ -1,4 +1,4 @@ -package ciutil +package hostinfo import ( "fmt" @@ -6,7 +6,7 @@ import ( ) /* -HostInfoInput allows different tools to specify the different possible values for their host-related +Input allows different tools to specify the different possible values for their host-related information. This is primarily used when downloading a release artifact. This is necessary to provide because different tools use different values for their host info. For example: @@ -14,7 +14,7 @@ provide because different tools use different values for their host info. For ex - In addition to a kernel ID, some tools additionally specify an OS value (e.g. both "darwin" and "macos") */ -type HostInfoInput struct { +type Input struct { // The name a tool uses for Linux OSes (e.g. "linux", "unknown", etc.) OSLinux string // The name a tool uses for the Linux kernel (e.g. "linux", "linux-gnu", etc.) @@ -29,15 +29,15 @@ type HostInfoInput struct { ArchARM64 string } -// HostInfo holds the final host information values to be used in e.g. release downloads of a tool. +// HostInfo holds the determined host information values. type HostInfo struct { OS string Arch string Kernel string } -// GetHostInfo returns a populated [HostInfo], based on the provided [HostInfoInput] mappings. -func GetHostInfo(i HostInfoInput) (HostInfo, error) { +// Get returns a populated [HostInfo], based on the provided [Input] mappings. +func Get(i Input) (HostInfo, error) { var out HostInfo switch runtime.GOOS { diff --git a/internal/oscarcfg/config.go b/internal/oscarcfg/config.go new file mode 100644 index 0000000..646a6c4 --- /dev/null +++ b/internal/oscarcfg/config.go @@ -0,0 +1,65 @@ +package oscarcfg + +import ( + "encoding/json" + "fmt" + "os" + + "buf.build/go/protovalidate" + "github.com/opensourcecorp/oscar/internal/consts" + oscarcfgpbv1 "github.com/opensourcecorp/oscar/internal/generated/opensourcecorp/oscar/config/v1" + iprint "github.com/opensourcecorp/oscar/internal/print" + "go.yaml.in/yaml/v4" + "golang.org/x/mod/semver" + "google.golang.org/protobuf/encoding/protojson" +) + +// Get returns a populated [Config] based on the oscar config file location. If `path` is not +// provided, it will default to looking in the calling directory. +func Get(pathOverride ...string) (*oscarcfgpbv1.Config, error) { + path := consts.DefaultOscarCfgFileName + + // Handle the override so we can test this function, and use it in other ways (like checking the + // main branch's version data) + if len(pathOverride) > 0 { + path = pathOverride[0] + } + + yamlData, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading oscar config file: %w", err) + } + iprint.Debugf("data read from oscar config file:\n%s\n", string(yamlData)) + + jsonSweepMap := make(map[string]any) + if err := yaml.Unmarshal(yamlData, jsonSweepMap); err != nil { + panic(err) + } + iprint.Debugf("YAML data unmarshalled to map: %+v\n", jsonSweepMap) + + jsonData, err := json.Marshal(jsonSweepMap) + if err != nil { + panic(err) + } + iprint.Debugf("map data as JSON string: %s\n", string(jsonData)) + + var cfg = &oscarcfgpbv1.Config{} + if err := protojson.Unmarshal(jsonData, cfg); err != nil { + return nil, fmt.Errorf("unmarshalling oscar config file '%s': %w", path, err) + } + iprint.Debugf("proto message: %+v\n", cfg) + + if err := protovalidate.Validate(cfg); err != nil { + return nil, fmt.Errorf("validating oscar config file '%s': %w", path, err) + } + + return cfg, nil +} + +// VersionHasBeenIncremented reports whether the newVersion is greater than the oldVersion. +func VersionHasBeenIncremented(newVersion string, oldVersion string) bool { + compValue := semver.Compare("v"+newVersion, "v"+oldVersion) + iprint.Debugf("semver comparison value: %d\n", compValue) + + return compValue > 0 +} diff --git a/internal/oscarcfg/config_test.go b/internal/oscarcfg/config_test.go new file mode 100644 index 0000000..e48f18a --- /dev/null +++ b/internal/oscarcfg/config_test.go @@ -0,0 +1,29 @@ +package oscarcfg + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testConfigFilePath = "test.oscar.yaml" + +func TestGet(t *testing.T) { + cfg, err := Get(testConfigFilePath) + require.NoError(t, err) + + t.Logf("parsed cfg:\n< %+v >", cfg) + + t.Run("version", func(t *testing.T) { + want := "1.0.0" + assert.Equal(t, want, cfg.GetVersion()) + }) + + t.Run("deliver", func(t *testing.T) { + wantBuildSources := []string{"./cmd/test"} + gotBuildSources := cfg.GetDeliverables().GetGoGithubRelease().GetBuildSources() + + assert.Equal(t, wantBuildSources, gotBuildSources) + }) +} diff --git a/internal/oscarcfg/doc.go b/internal/oscarcfg/doc.go new file mode 100644 index 0000000..bb255f0 --- /dev/null +++ b/internal/oscarcfg/doc.go @@ -0,0 +1,2 @@ +// Package oscarcfg defines types & behavior for working with oscar's config file format. +package oscarcfg diff --git a/internal/oscarcfg/test.oscar.yaml b/internal/oscarcfg/test.oscar.yaml new file mode 100644 index 0000000..61f8f2f --- /dev/null +++ b/internal/oscarcfg/test.oscar.yaml @@ -0,0 +1,11 @@ +--- +version: "1.0.0" +deliverables: + go_github_release: + build_sources: + - "./cmd/test" + draft: false + container_image: + registry: "ghcr.io" + namespace: "opensourcecorp" + name: "oscar" diff --git a/internal/print/print.go b/internal/print/print.go index b3cd9fd..63a3e0d 100644 --- a/internal/print/print.go +++ b/internal/print/print.go @@ -10,12 +10,12 @@ import ( // Banner prints the oscar banner. func Banner() { var banner = ` - ____________________ - /____________________/| -| _ _ _ _ _ |/| -| | | |_ | |_| |_| |/| -| |_| _| |_ | | | \ |/| -|____________________|/ + ____________________ \ +=~=~=~=~=/____________________/|---------\ +~=~=~=~=| _ _ _ _ _ |/|----------\ +=~=~=~=~| | | |_ | |_| |_| |/|----------/ +~=~=~=~=| |_| _| |_ | | | \ |/|---------/ + |____________________|/ / ` fmt.Println(banner) } diff --git a/internal/semver/doc.go b/internal/semver/doc.go deleted file mode 100644 index 583bab4..0000000 --- a/internal/semver/doc.go +++ /dev/null @@ -1,5 +0,0 @@ -// Package semver provides tooling for working with version information, in a way that conforms with -// the [Semantic Versioning] approach. -// -// [Semantic Versioning]: https://semver.org -package semver diff --git a/internal/semver/semver.go b/internal/semver/semver.go deleted file mode 100644 index b803f6e..0000000 --- a/internal/semver/semver.go +++ /dev/null @@ -1,54 +0,0 @@ -package semver - -import ( - "fmt" - "regexp" - "strings" - - iprint "github.com/opensourcecorp/oscar/internal/print" - "golang.org/x/mod/semver" -) - -// GetSemver tries to build a (Go) compliant Semantic Version number out of the provided string, -// regardless of how dirty it is. Despite using the semver package in a few places internally, most -// of this implementation is custom due to limitations in that package -- like not being able to -// parse out just the Patch number, or Pre-Release/Build numbers not being allowed in -// semver.Canonical() -func GetSemver(s string) (string, error) { - var v, preRelease, build string - - // Grab the semver parts separately so we can clean them up. Firstly, the Major-Minor-Patch - // parts, but Patch takes some extra work to suss out -- if there's any prerelease or build - // parts of the version, these show up in the "patch" index if we just split on dots, so we can - // just grab the whole MMP with a regex - v = "v" + regexp.MustCompile(`\d+(\.\d+)?(\.\d+)?`).FindStringSubmatch(s)[0] - v = semver.Canonical(v) - - // Gross - prSplit := strings.Split(s, "-") - bSplit := strings.Split(s, "+") - if len(prSplit) > 1 { - // could still have a build number - preRelease = strings.Split(prSplit[1], "+")[0] - } - if len(bSplit) > 1 { - build = bSplit[1] - } - - if preRelease != "" { - v += "-" + preRelease - } - if build != "" { - v += "+" + build - } - - if !semver.IsValid(v) { - return "", fmt.Errorf("could not understand the semantic version you provided in your Oscarfile: '%s'", s) - } - - if v != s { - iprint.Warnf("the Semantic Version string built was different from the one provided -- please edit your version to match the correct format: %s --> %s\n", s, v) - } - - return v, nil -} diff --git a/internal/semver/semver_test.go b/internal/semver/semver_test.go deleted file mode 100644 index b39f6ca..0000000 --- a/internal/semver/semver_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package semver - -import ( - "testing" -) - -func TestGetSemver(t *testing.T) { - t.Run("Convert regular semver to be conformant", func(t *testing.T) { - s := "1.0.0.0" - want := "v1.0.0" - got, err := GetSemver(s) - if err != nil { - t.Errorf("unexpected error") - } - if want != got { - t.Errorf("Expected version string '%s' to become '%s', but got '%s'\n", s, want, got) - } - }) - - t.Run("Convert semver with prerelease info to be conformant", func(t *testing.T) { - s := "1.0-alpha" - want := "v1.0.0-alpha" - got, err := GetSemver(s) - if err != nil { - t.Errorf("unexpected error") - } - if want != got { - t.Errorf("Expected version string '%s' to become '%s', but got '%s'\n", s, want, got) - } - }) - - t.Run("Convert semver with build info to be conformant", func(t *testing.T) { - s := "1.0.2+abc" - want := "v1.0.2+abc" - got, err := GetSemver(s) - if err != nil { - t.Errorf("unexpected error") - } - if want != got { - t.Errorf("Expected version string '%s' to become '%s', but got '%s'\n", s, want, got) - } - }) - - t.Run("No conversion on a conformant semver", func(t *testing.T) { - s := "v1.1.9-prebeta1+abc" - want := s - got, err := GetSemver(s) - if err != nil { - t.Errorf("unexpected error") - } - if want != got { - t.Errorf("Expected version string '%s' to become '%s', but got '%s'\n", s, want, got) - } - }) -} diff --git a/internal/ci/doc.go b/internal/tasks/ci/doc.go similarity index 100% rename from internal/ci/doc.go rename to internal/tasks/ci/doc.go diff --git a/internal/tasks/ci/run.go b/internal/tasks/ci/run.go new file mode 100644 index 0000000..fa66e30 --- /dev/null +++ b/internal/tasks/ci/run.go @@ -0,0 +1,140 @@ +package ci + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + "github.com/opensourcecorp/oscar/internal/consts" + igit "github.com/opensourcecorp/oscar/internal/git" + iprint "github.com/opensourcecorp/oscar/internal/print" + containertools "github.com/opensourcecorp/oscar/internal/tasks/tools/containers" + gotools "github.com/opensourcecorp/oscar/internal/tasks/tools/go" + mdtools "github.com/opensourcecorp/oscar/internal/tasks/tools/markdown" + pytools "github.com/opensourcecorp/oscar/internal/tasks/tools/python" + shtools "github.com/opensourcecorp/oscar/internal/tasks/tools/shell" + versiontools "github.com/opensourcecorp/oscar/internal/tasks/tools/version" + yamltools "github.com/opensourcecorp/oscar/internal/tasks/tools/yaml" + taskutil "github.com/opensourcecorp/oscar/internal/tasks/util" +) + +// getCITaskMap assembles the overall list of CI tasks, keyed by their language/tooling name +func getCITaskMap(repo taskutil.Repo) (taskutil.TaskMap, error) { + out := make(taskutil.TaskMap) + for langName, getTasksFunc := range map[string]func(taskutil.Repo) []taskutil.Tasker{ + "Versioning": versiontools.NewTasksForCI, + "Go": gotools.NewTasksForCI, + "Python": pytools.NewTasksForCI, + // "Terraform": tftools.NewTasksForCI, + "YAML": yamltools.NewTasksForCI, + "Containerfile": containertools.NewTasksForCI, + "Shell": shtools.NewTasksForCI, + "Markdown": mdtools.NewTasksForCI, + } { + tasks := getTasksFunc(repo) + if len(tasks) > 0 { + out[langName] = tasks + } + } + + iprint.Debugf("getCITaskMap output: %#v\n", out) + + return out, nil +} + +// Run defines the behavior for running all CI tasks for the repository. +func Run(ctx context.Context) (err error) { + // The mise config that oscar uses is written during init, so be sure to defer its removal here + defer func() { + if rmErr := os.RemoveAll(consts.MiseConfigFileName); rmErr != nil { + err = errors.Join(err, fmt.Errorf("removing mise config file: %w", rmErr)) + } + }() + + run, err := taskutil.NewRun(ctx, "CI") + if err != nil { + return fmt.Errorf("internal error setting up run info: %w", err) + } + + git, err := igit.New(ctx) + if err != nil { + return err + } + fmt.Print(git.String()) + + repo, err := taskutil.NewRepo(ctx) + if err != nil { + return fmt.Errorf("getting repo composition: %w", err) + } + fmt.Print(repo.String()) + + taskMap, err := getCITaskMap(repo) + if err != nil { + return err + } + + // For tracking any changes to Git status etc. after each CI Task runs + gitCI, err := igit.NewForCI(ctx) + if err != nil { + return fmt.Errorf("internal error: %w", err) + } + + for _, lang := range taskMap.SortedKeys() { + tasks := taskMap[lang] + + run.PrintTaskMapBanner(lang) + for _, task := range tasks { + taskStartTime := time.Now() + run.PrintTaskBanner(task) + + // NOTE: this error is checked later, when we can check the Run, Post, and git-diff + // potential errors together + var runErr error + runErr = errors.Join(runErr, task.Exec(ctx)) + runErr = errors.Join(runErr, task.Post(ctx)) + + if err := gitCI.Update(ctx); err != nil { + return fmt.Errorf("internal error: %w", err) + } + gitStatusHasChanged, err := gitCI.StatusHasChanged(ctx) + if err != nil { + return fmt.Errorf("internal error: %w", err) + } + + if runErr != nil || gitStatusHasChanged { + iprint.Errorf("FAILED (%s)\n", taskutil.RunDurationString(taskStartTime)) + iprint.Errorf("\n") + + if runErr != nil { + iprint.Errorf("%v\n", runErr) + } + + if gitStatusHasChanged { + iprint.Errorf("Files ~CHANGED~ during run: %+v\n", gitCI.CurrentStatus.Diff) + iprint.Errorf("Files +CREATED+ during run: %+v\n", gitCI.CurrentStatus.UntrackedFiles) + iprint.Errorf("\n") + } + + run.Failures = append(run.Failures, fmt.Sprintf("%s :: %s", lang, task.InfoText())) + + // Also need to reset the baseline status + gitCI, err = igit.NewForCI(ctx) + if err != nil { + return fmt.Errorf("internal error: %w", err) + } + } else { + fmt.Printf("PASSED (%s)\n", taskutil.RunDurationString(taskStartTime)) + } + } + } + + if len(run.Failures) > 0 { + return run.ReportFailure(err) + } + + run.ReportSuccess() + + return err +} diff --git a/internal/tasks/delivery/doc.go b/internal/tasks/delivery/doc.go new file mode 100644 index 0000000..a3930d6 --- /dev/null +++ b/internal/tasks/delivery/doc.go @@ -0,0 +1,2 @@ +// Package delivery defines behavior for running delivery tasks. +package delivery diff --git a/internal/tasks/delivery/run.go b/internal/tasks/delivery/run.go new file mode 100644 index 0000000..d4fe11a --- /dev/null +++ b/internal/tasks/delivery/run.go @@ -0,0 +1,117 @@ +package delivery + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + "github.com/opensourcecorp/oscar/internal/consts" + igit "github.com/opensourcecorp/oscar/internal/git" + iprint "github.com/opensourcecorp/oscar/internal/print" + "github.com/opensourcecorp/oscar/internal/tasks/ci" + containertools "github.com/opensourcecorp/oscar/internal/tasks/tools/containers" + gittagtools "github.com/opensourcecorp/oscar/internal/tasks/tools/gittag" + gotools "github.com/opensourcecorp/oscar/internal/tasks/tools/go" + taskutil "github.com/opensourcecorp/oscar/internal/tasks/util" +) + +// getDeliveryTaskMap assembles the overall list of Delivery tasks, keyed by their language/tooling +// name. +func getDeliveryTaskMap(repo taskutil.Repo) (taskutil.TaskMap, error) { + out := make(taskutil.TaskMap) + for langName, getTasksFunc := range map[string]func(taskutil.Repo) ([]taskutil.Tasker, error){ + // Independent of Delivery tasks, always push a Git Tag first + "0 - Create Git Tag": gittagtools.NewTasksForDelivery, + "Go": gotools.NewTasksForDelivery, + "OCI Images": containertools.NewTasksForDelivery, + // "Python": pytools.NewTasksForDelivery, + // "Terraform": tftools.NewTasksForDelivery, + // "Markdown": mdtools.NewTasksForDelivery, + } { + tasks, err := getTasksFunc(repo) + if err != nil { + return nil, err + } + + if len(tasks) > 0 { + out[langName] = tasks + } + } + + iprint.Debugf("getDeliveryTaskMap output: %#v\n", out) + + return out, nil +} + +// Run defines the behavior for running all Delivery tasks for the repository. +func Run(ctx context.Context) (err error) { + // We intentionally run CI tasks before allowing any Delivery tasks to begin + if err := ci.Run(ctx); err != nil { + return fmt.Errorf("running CI tasks before Delivery tasks: %w", err) + } + + // The mise config that oscar uses is written during init, so be sure to defer its removal here + defer func() { + if rmErr := os.RemoveAll(consts.MiseConfigFileName); rmErr != nil { + err = errors.Join(err, fmt.Errorf("removing mise config file: %w", rmErr)) + } + }() + + run, err := taskutil.NewRun(ctx, "Deliver") + if err != nil { + return fmt.Errorf("internal error setting up run info: %w", err) + } + + git, err := igit.New(ctx) + if err != nil { + return err + } + fmt.Print(git.String()) + + repo, err := taskutil.NewRepo(ctx) + if err != nil { + return fmt.Errorf("getting repo composition: %w", err) + } + fmt.Print(repo.String()) + + taskMap, err := getDeliveryTaskMap(repo) + if err != nil { + return err + } + + for _, lang := range taskMap.SortedKeys() { + tasks := taskMap[lang] + + run.PrintTaskMapBanner(lang) + + for _, task := range tasks { + taskStartTime := time.Now() + run.PrintTaskBanner(task) + + // NOTE: this error is checked later, when we can check the Run, Post, and git-diff + // potential errors together + var runErr error + runErr = errors.Join(runErr, task.Exec(ctx)) + runErr = errors.Join(runErr, task.Post(ctx)) + + if runErr != nil { + iprint.Errorf("FAILED (%s)\n", taskutil.RunDurationString(taskStartTime)) + iprint.Errorf("%v\n", runErr) + + run.Failures = append(run.Failures, fmt.Sprintf("%s :: %s", lang, task.InfoText())) + } else { + fmt.Printf("SUCCEEDED (%s)\n", taskutil.RunDurationString(taskStartTime)) + } + } + } + + if len(run.Failures) > 0 { + return run.ReportFailure(err) + } + + run.ReportSuccess() + + return err +} diff --git a/internal/tasks/tools/containers/ci.go b/internal/tasks/tools/containers/ci.go new file mode 100644 index 0000000..a0bd769 --- /dev/null +++ b/internal/tasks/tools/containers/ci.go @@ -0,0 +1,55 @@ +package containertools + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/opensourcecorp/oscar/internal/tasks/tools/toolcfg" + taskutil "github.com/opensourcecorp/oscar/internal/tasks/util" +) + +type ( + hadolint struct{ taskutil.Tool } +) + +// NewTasksForCI returns the list of CI tasks. +func NewTasksForCI(repo taskutil.Repo) []taskutil.Tasker { + if repo.HasContainerfile { + return []taskutil.Tasker{ + hadolint{ + Tool: taskutil.Tool{ + RunArgs: []string{"bash", "-c", + fmt.Sprintf( + `hadolint --config {{ConfigFilePath}} $(%s)`, + taskutil.GetFileTypeListerCommand("containerfile"), + ), + }, + ConfigFilePath: filepath.Join(os.TempDir(), "hadolint.yaml"), + }, + }, + } + } + + return nil +} + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t hadolint) InfoText() string { return "Lint (hadolint)" } + +// Run implements [taskutil.Tasker.Run]. +func (t hadolint) Exec(ctx context.Context) error { + if err := toolcfg.SetupConfigFile(t.Tool); err != nil { + return err + } + + if _, err := taskutil.RunCommand(ctx, t.RenderRunCommandArgs()); err != nil { + return err + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t hadolint) Post(_ context.Context) error { return nil } diff --git a/internal/tasks/tools/containers/deliver.go b/internal/tasks/tools/containers/deliver.go new file mode 100644 index 0000000..634f9ba --- /dev/null +++ b/internal/tasks/tools/containers/deliver.go @@ -0,0 +1,165 @@ +package containertools + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + oscarcfgpbv1 "github.com/opensourcecorp/oscar/internal/generated/opensourcecorp/oscar/config/v1" + igit "github.com/opensourcecorp/oscar/internal/git" + "github.com/opensourcecorp/oscar/internal/oscarcfg" + iprint "github.com/opensourcecorp/oscar/internal/print" + taskutil "github.com/opensourcecorp/oscar/internal/tasks/util" + "go.yaml.in/yaml/v4" +) + +type ( + imageBuildPush struct{ taskutil.Tool } +) + +// registryMapping contains substructs to be used based on the target OCI registry. +type registryMapping struct { + GitHub gitHubRegistry +} + +// gitHubRegistry provides fields for use in targeting "ghcr.io". +type gitHubRegistry struct { + // The command to run to authenticate to the registry. + AuthCommand []string +} + +// newRegistryMap returns a populated [registryMapping]. +func newRegistryMap(username string) registryMapping { + return registryMapping{ + GitHub: gitHubRegistry{ + AuthCommand: []string{"bash", "-c", fmt.Sprintf(` + echo ${GITHUB_TOKEN} | docker login ghcr.io --username %s --password-stdin + `, username, + )}, + }, + } +} + +// NewTasksForDelivery returns the list of CI tasks. +func NewTasksForDelivery(repo taskutil.Repo) ([]taskutil.Tasker, error) { + cfg, err := oscarcfg.Get() + if err != nil { + return nil, err + } + + if repo.HasContainerfile { + out := make([]taskutil.Tasker, 0) + + if cfg.GetDeliverables().GetContainerImage() != nil { + out = append(out, imageBuildPush{}) + } + + return out, nil + } + + return nil, nil +} + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t imageBuildPush) InfoText() string { return "Image Build & Push" } + +// Run implements [taskutil.Tasker.Run]. +func (t imageBuildPush) Exec(ctx context.Context) error { + rootCfg, err := oscarcfg.Get() + if err != nil { + return err + } + cfg := rootCfg.GetDeliverables().GetContainerImage() + + uri, err := constructImageURI(ctx, rootCfg) + if err != nil { + return fmt.Errorf("constructing image URI: %w", err) + } + + composeFileContents, err := os.ReadFile("docker-compose.yaml") + if err != nil { + return err + } + + composeFile := make(map[string]any) + if err := yaml.Unmarshal(composeFileContents, composeFile); err != nil { + return err + } + iprint.Debugf("composeFile unmarshalled: %#v\n", composeFile) + + curDir, err := os.Getwd() + if err != nil { + return err + } + + // GROSS, DUDE + composeFile["services"].(map[string]any)[cfg.GetName()].(map[string]any)["image"] = uri + composeFile["services"].(map[string]any)[cfg.GetName()].(map[string]any)["build"].(map[string]any)["context"] = curDir + + composeOut, err := yaml.Marshal(composeFile) + if err != nil { + return err + } + iprint.Debugf("edited Compose file YAML: %s\n", string(composeOut)) + + workDir := filepath.Join(os.TempDir(), "oscar-oci") + if err := os.MkdirAll(workDir, 0755); err != nil { + return err + } + + outPath := filepath.Join(workDir, "docker-compose.yaml") + if err := os.WriteFile(outPath, composeOut, 0644); err != nil { + return err + } + + registryMap := newRegistryMap(cfg.GetName()) + + var authArgs []string + if strings.Contains(cfg.Registry, "ghcr") { + authArgs = registryMap.GitHub.AuthCommand + } + + if _, err := taskutil.RunCommand(ctx, authArgs); err != nil { + return err + } + + buildPushArgs := []string{"bash", "-c", fmt.Sprintf(` + docker compose --file %s build --push %s + `, outPath, cfg.GetName(), + )} + if _, err := taskutil.RunCommand(ctx, buildPushArgs); err != nil { + return err + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t imageBuildPush) Post(_ context.Context) error { return nil } + +// constructImageURI constructs an image URI based on data from oscar's config & Git. +func constructImageURI(ctx context.Context, rootCfg *oscarcfgpbv1.Config) (string, error) { + cfg := rootCfg.GetDeliverables().GetContainerImage() + git, err := igit.New(ctx) + if err != nil { + return "", fmt.Errorf("getting Git info: %w", err) + } + + tag := rootCfg.GetVersion() + if git.Branch != "main" { + tag = fmt.Sprintf("%s-%s", git.SanitizedBranch(), git.LatestCommit) + } + if git.IsDirty { + tag = fmt.Sprintf("%s-%s-dirty", git.SanitizedBranch(), git.LatestCommit) + } + + uri := fmt.Sprintf( + "%s/%s/%s:%s", + cfg.GetRegistry(), cfg.GetNamespace(), cfg.GetName(), tag, + ) + iprint.Debugf("image URI: %s\n", uri) + + return uri, nil +} diff --git a/internal/tasks/tools/containers/deliver_test.go b/internal/tasks/tools/containers/deliver_test.go new file mode 100644 index 0000000..30a7a55 --- /dev/null +++ b/internal/tasks/tools/containers/deliver_test.go @@ -0,0 +1,32 @@ +package containertools + +import ( + "context" + "regexp" + "testing" + + igit "github.com/opensourcecorp/oscar/internal/git" + "github.com/opensourcecorp/oscar/internal/oscarcfg" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConstructImageURI(t *testing.T) { + cfg, err := oscarcfg.Get("../../../oscarcfg/test.oscar.yaml") + require.NoError(t, err) + + git, err := igit.New(context.Background()) + require.NoError(t, err) + + var want *regexp.Regexp + if git.Branch == "main" { + want = regexp.MustCompile(`ghcr.io/opensourcecorp/oscar:` + cfg.GetVersion()) + } else { + want = regexp.MustCompile(`ghcr.io/opensourcecorp/oscar:.*-.*-.*`) + } + + got, err := constructImageURI(context.Background(), cfg) + require.NoError(t, err) + + assert.Regexp(t, want, got) +} diff --git a/internal/tasks/tools/containers/doc.go b/internal/tasks/tools/containers/doc.go new file mode 100644 index 0000000..95cd90c --- /dev/null +++ b/internal/tasks/tools/containers/doc.go @@ -0,0 +1,3 @@ +// Package containertools contains logic for running tasks for OCI containers (including +// Containerfiles). +package containertools diff --git a/internal/tasks/tools/gittag/deliver.go b/internal/tasks/tools/gittag/deliver.go new file mode 100644 index 0000000..54cfb99 --- /dev/null +++ b/internal/tasks/tools/gittag/deliver.go @@ -0,0 +1,48 @@ +package gittagtools + +import ( + "context" + "fmt" + + "github.com/opensourcecorp/oscar/internal/oscarcfg" + taskutil "github.com/opensourcecorp/oscar/internal/tasks/util" +) + +type ( + createAndPushTag struct{ taskutil.Tool } +) + +// NewTasksForDelivery returns the list of Delivery tasks. +func NewTasksForDelivery(_ taskutil.Repo) ([]taskutil.Tasker, error) { + out := []taskutil.Tasker{ + createAndPushTag{}, + } + + return out, nil +} + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t createAndPushTag) InfoText() string { return "Create & Push Git Tag" } + +// Exec implements [taskutil.Tasker.Exec]. +func (t createAndPushTag) Exec(ctx context.Context) error { + cfg, err := oscarcfg.Get() + if err != nil { + return err + } + + args := []string{"bash", "-c", fmt.Sprintf(` + git tag v%s + git push --tags + `, cfg.GetVersion(), + )} + + if _, err := taskutil.RunCommand(ctx, args); err != nil { + return err + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t createAndPushTag) Post(_ context.Context) error { return nil } diff --git a/internal/tasks/tools/gittag/doc.go b/internal/tasks/tools/gittag/doc.go new file mode 100644 index 0000000..b34ae7a --- /dev/null +++ b/internal/tasks/tools/gittag/doc.go @@ -0,0 +1,2 @@ +// Package gittagtools contains logic for running tasks for Git Tags. +package gittagtools diff --git a/internal/tasks/tools/go/ci.go b/internal/tasks/tools/go/ci.go new file mode 100644 index 0000000..7968673 --- /dev/null +++ b/internal/tasks/tools/go/ci.go @@ -0,0 +1,297 @@ +package gotools + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/opensourcecorp/oscar/internal/tasks/tools/toolcfg" + taskutil "github.com/opensourcecorp/oscar/internal/tasks/util" +) + +type ( + goModCheck struct{ taskutil.Tool } + goFormat struct{ taskutil.Tool } + generateCodeCI struct{ taskutil.Tool } + goBuildCI struct{ taskutil.Tool } + goVet struct{ taskutil.Tool } + staticcheck struct{ taskutil.Tool } + revive struct{ taskutil.Tool } + errcheck struct{ taskutil.Tool } + goImports struct{ taskutil.Tool } + govulncheck struct{ taskutil.Tool } + goTest struct{ taskutil.Tool } +) + +// NewTasksForCI returns the list of CI tasks. +func NewTasksForCI(repo taskutil.Repo) []taskutil.Tasker { + if repo.HasGo { + return []taskutil.Tasker{ + goModCheck{ + Tool: taskutil.Tool{ + RunArgs: []string{"go", "mod", "tidy"}, + }, + }, + goFormat{ + Tool: taskutil.Tool{ + RunArgs: []string{"go", "fmt", "./..."}, + }, + }, + goImports{ + Tool: taskutil.Tool{ + RunArgs: []string{"goimports", "-l", "-w", "."}, + }, + }, + generateCodeCI{ + Tool: taskutil.Tool{ + RunArgs: []string{"go", "generate", "./..."}, + }, + }, + goBuildCI{ + Tool: taskutil.Tool{ + RunArgs: []string{"go", "build", "./..."}, + }, + }, + goVet{ + Tool: taskutil.Tool{ + RunArgs: []string{"go", "vet", "./..."}, + }, + }, + staticcheck{ + Tool: taskutil.Tool{ + RunArgs: []string{"staticcheck", "./..."}, + // NOTE: staticcheck does not have a flag to point to a config file, so we need + // to put it at the repo root + ConfigFilePath: filepath.Join("staticcheck.conf"), + }, + }, + revive{ + Tool: taskutil.Tool{ + RunArgs: []string{ + "revive", "--config", "{{ConfigFilePath}}", "--set_exit_status", "./...", + }, + ConfigFilePath: filepath.Join(os.TempDir(), "revive.toml"), + }, + }, + errcheck{ + Tool: taskutil.Tool{ + RunArgs: []string{"errcheck", "./..."}, + }, + }, + govulncheck{ + Tool: taskutil.Tool{ + RunArgs: []string{"govulncheck", "./..."}, + }, + }, + goTest{ + Tool: taskutil.Tool{ + RunArgs: []string{"go", "test", "./..."}, + }, + }, + } + } + + return nil +} + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t goModCheck) InfoText() string { return "go.mod tidy check" } + +// Exec implements [taskutil.Tasker.Exec]. +func (t goModCheck) Exec(ctx context.Context) error { + if _, err := taskutil.RunCommand(ctx, t.RunArgs); err != nil { + return err + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t goModCheck) Post(_ context.Context) error { return nil } + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t goFormat) InfoText() string { return "Format" } + +// Exec implements [taskutil.Tasker.Exec]. +func (t goFormat) Exec(ctx context.Context) error { + if _, err := taskutil.RunCommand(ctx, t.RunArgs); err != nil { + return err + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t goFormat) Post(_ context.Context) error { return nil } + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t goImports) InfoText() string { return "Format imports" } + +// Exec implements [taskutil.Tasker.Exec]. +func (t goImports) Exec(ctx context.Context) error { + if _, err := taskutil.RunCommand(ctx, t.RunArgs); err != nil { + return err + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t goImports) Post(_ context.Context) error { return nil } + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t generateCodeCI) InfoText() string { return "Generate code" } + +// Exec implements [taskutil.Tasker.Exec]. +func (t generateCodeCI) Exec(ctx context.Context) error { + if _, err := taskutil.RunCommand(ctx, t.RunArgs); err != nil { + return err + } + + // Generating code will likely throw diffs if not also addressing other formatting CI checks, so + // run those here again as well. + tasks := NewTasksForCI(taskutil.Repo{HasGo: true}) + for _, task := range tasks { + if wantToRun, isOfType := task.(goFormat); isOfType { + if err := wantToRun.Exec(ctx); err != nil { + return fmt.Errorf("running Go formatter after code generation: %w", err) + } + } + if wantToRun, isOfType := task.(goImports); isOfType { + if err := wantToRun.Exec(ctx); err != nil { + return fmt.Errorf("running Go formatter after code generation: %w", err) + } + } + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t generateCodeCI) Post(_ context.Context) error { return nil } + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t goBuildCI) InfoText() string { return "Build" } + +// Exec implements [taskutil.Tasker.Exec]. +func (t goBuildCI) Exec(ctx context.Context) error { + if _, err := taskutil.RunCommand(ctx, t.RunArgs); err != nil { + return err + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t goBuildCI) Post(_ context.Context) error { return nil } + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t goVet) InfoText() string { return "Vet" } + +// Exec implements [taskutil.Tasker.Exec]. +func (t goVet) Exec(ctx context.Context) error { + if _, err := taskutil.RunCommand(ctx, t.RunArgs); err != nil { + return err + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t goVet) Post(_ context.Context) error { return nil } + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t staticcheck) InfoText() string { return "Lint (staticcheck)" } + +// Exec implements [taskutil.Tasker.Exec]. +func (t staticcheck) Exec(ctx context.Context) error { + if err := toolcfg.SetupConfigFile(t.Tool); err != nil { + return err + } + + if _, err := taskutil.RunCommand(ctx, t.RunArgs); err != nil { + return err + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t staticcheck) Post(_ context.Context) error { + if err := os.RemoveAll(t.ConfigFilePath); err != nil { + return fmt.Errorf("removing config file: %w", err) + } + + return nil +} + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t revive) InfoText() string { return "Lint (revive)" } + +// Exec implements [taskutil.Tasker.Exec]. +func (t revive) Exec(ctx context.Context) error { + if err := toolcfg.SetupConfigFile(t.Tool); err != nil { + return err + } + + if _, err := taskutil.RunCommand(ctx, t.RenderRunCommandArgs()); err != nil { + return err + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t revive) Post(_ context.Context) error { + if err := os.RemoveAll(t.ConfigFilePath); err != nil { + return fmt.Errorf("removing config file: %w", err) + } + + return nil +} + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t errcheck) InfoText() string { return "Lint (errcheck)" } + +// Exec implements [taskutil.Tasker.Exec]. +func (t errcheck) Exec(ctx context.Context) error { + if _, err := taskutil.RunCommand(ctx, t.RunArgs); err != nil { + return err + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t errcheck) Post(_ context.Context) error { return nil } + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t govulncheck) InfoText() string { return "Vulnerability scan (govulncheck)" } + +// Exec implements [taskutil.Tasker.Exec]. +func (t govulncheck) Exec(ctx context.Context) error { + if _, err := taskutil.RunCommand(ctx, t.RunArgs); err != nil { + return err + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t govulncheck) Post(_ context.Context) error { return nil } + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t goTest) InfoText() string { return "Tests" } + +// Exec implements [taskutil.Tasker.Exec]. +func (t goTest) Exec(ctx context.Context) error { + if _, err := taskutil.RunCommand(ctx, t.RunArgs); err != nil { + return err + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t goTest) Post(_ context.Context) error { return nil } diff --git a/internal/tasks/tools/go/deliver.go b/internal/tasks/tools/go/deliver.go new file mode 100644 index 0000000..6aeb5e7 --- /dev/null +++ b/internal/tasks/tools/go/deliver.go @@ -0,0 +1,139 @@ +package gotools + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/opensourcecorp/oscar/internal/oscarcfg" + taskutil "github.com/opensourcecorp/oscar/internal/tasks/util" +) + +type ( + ghRelease struct{ taskutil.Tool } +) + +// NewTasksForDelivery returns the list of Delivery tasks. +func NewTasksForDelivery(repo taskutil.Repo) ([]taskutil.Tasker, error) { + cfg, err := oscarcfg.Get() + if err != nil { + return nil, err + } + + if repo.HasGo { + out := make([]taskutil.Tasker, 0) + + if cfg.GetDeliverables().GetGoGithubRelease() != nil { + out = append(out, ghRelease{}) + } + + return out, nil + } + + return nil, nil +} + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t ghRelease) InfoText() string { return "GitHub Release" } + +// Exec implements [taskutil.Tasker.Exec]. +func (t ghRelease) Exec(ctx context.Context) error { + cfg, err := oscarcfg.Get() + if err != nil { + return err + } + + var buildErr error + for _, src := range cfg.GetDeliverables().GetGoGithubRelease().GetBuildSources() { + buildErr = goBuild(ctx, src) + } + if buildErr != nil { + return err + } + + buildDir := "build" + distDir := "dist" + + if err := os.RemoveAll(distDir); err != nil { + return fmt.Errorf("removing dist directory: %w", err) + } + + if err := os.MkdirAll(distDir, 0755); err != nil { + return fmt.Errorf("creating dist directory: %w", err) + } + + if err := os.CopyFS(distDir, os.DirFS(buildDir)); err != nil { + return fmt.Errorf("copying build artifacts to %s: %w", distDir, err) + } + + draftFlag := "" + if cfg.GetDeliverables().GetGoGithubRelease().GetDraft() { + draftFlag = "--draft" + } + + args := []string{"bash", "-c", fmt.Sprintf(` + gh release create v%s %s --generate-notes --verify-tag --latest ./dist/* + `, cfg.GetVersion(), draftFlag, + )} + + if _, err := taskutil.RunCommand(ctx, args); err != nil { + return err + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t ghRelease) Post(_ context.Context) error { return nil } + +// goBuild cross-compiles the provided source package and places the resulting artifacts in a +// root-level "build/" subdirectory. +func goBuild(ctx context.Context, src string) error { + if strings.HasSuffix(src, ".go") { + return fmt.Errorf("provided Go build source '%s' was a file, but must be a path to a package", src) + } + + targetDir := "build" + + if err := os.RemoveAll(targetDir); err != nil { + return fmt.Errorf("removing build directory: %w", err) + } + + if err := os.MkdirAll(targetDir, 0755); err != nil { + return fmt.Errorf("creating build directory: %w", err) + } + + distros := []string{ + "linux/amd64", + "linux/arm64", + "darwin/amd64", + "darwin/arm64", + } + + for _, distro := range distros { + splits := strings.Split(distro, "/") + goos := splits[0] + goarch := splits[1] + + binName := filepath.Base(src) + target := filepath.Join(targetDir, fmt.Sprintf("%s-%s-%s", binName, goos, goarch)) + + if _, err := taskutil.RunCommand(ctx, []string{"bash", "-c", fmt.Sprintf(` + CGO_ENABLED=0 \ + GOOS=%s GOARCH=%s \ + go build -ldflags '-extldflags "-static"' -o %s %s`, + goos, goarch, + target, src, + )}); err != nil { + return fmt.Errorf("building Go binary: %w", err) + } + + if err := os.Chmod(target, 0755); err != nil { + return fmt.Errorf("marking target as executable: %w", err) + } + } + + return nil +} diff --git a/internal/tasks/tools/go/doc.go b/internal/tasks/tools/go/doc.go new file mode 100644 index 0000000..9efa156 --- /dev/null +++ b/internal/tasks/tools/go/doc.go @@ -0,0 +1,2 @@ +// Package gotools contains logic for running tasks for Go. +package gotools diff --git a/internal/tasks/tools/markdown/ci.go b/internal/tasks/tools/markdown/ci.go new file mode 100644 index 0000000..abc3938 --- /dev/null +++ b/internal/tasks/tools/markdown/ci.go @@ -0,0 +1,49 @@ +package mdtools + +import ( + "context" + "os" + "path/filepath" + + "github.com/opensourcecorp/oscar/internal/tasks/tools/toolcfg" + taskutil "github.com/opensourcecorp/oscar/internal/tasks/util" +) + +type ( + markdownlint struct{ taskutil.Tool } +) + +// NewTasksForCI returns the list of CI tasks. +func NewTasksForCI(repo taskutil.Repo) []taskutil.Tasker { + if repo.HasMarkdown { + return []taskutil.Tasker{ + markdownlint{ + Tool: taskutil.Tool{ + RunArgs: []string{"markdownlint-cli2", "--config", "{{ConfigFilePath}}", "**/*.md"}, + ConfigFilePath: filepath.Join(os.TempDir(), ".markdownlint-cli2.yaml"), + }, + }, + } + } + + return nil +} + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t markdownlint) InfoText() string { return "Lint (markdownlint)" } + +// Exec implements [taskutil.Tasker.Exec]. +func (t markdownlint) Exec(ctx context.Context) error { + if err := toolcfg.SetupConfigFile(t.Tool); err != nil { + return err + } + + if _, err := taskutil.RunCommand(ctx, t.RenderRunCommandArgs()); err != nil { + return err + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t markdownlint) Post(_ context.Context) error { return nil } diff --git a/internal/tasks/tools/markdown/doc.go b/internal/tasks/tools/markdown/doc.go new file mode 100644 index 0000000..0fe2c56 --- /dev/null +++ b/internal/tasks/tools/markdown/doc.go @@ -0,0 +1,2 @@ +// Package mdtools contains logic for running tasks for Markdown. +package mdtools diff --git a/internal/tasks/tools/python/ci.go b/internal/tasks/tools/python/ci.go new file mode 100644 index 0000000..a53e15f --- /dev/null +++ b/internal/tasks/tools/python/ci.go @@ -0,0 +1,125 @@ +package pytools + +import ( + "context" + + taskutil "github.com/opensourcecorp/oscar/internal/tasks/util" +) + +type ( + buildTask struct{ taskutil.Tool } + ruffLint struct{ taskutil.Tool } + ruffFormat struct{ taskutil.Tool } + pydoclint struct{ taskutil.Tool } + mypy struct{ taskutil.Tool } +) + +// NewTasksForCI returns the list of CI tasks. +func NewTasksForCI(repo taskutil.Repo) []taskutil.Tasker { + if repo.HasPython { + return []taskutil.Tasker{ + buildTask{ + Tool: taskutil.Tool{ + RunArgs: []string{"uv", "build"}, + }, + }, + ruffLint{ + Tool: taskutil.Tool{ + RunArgs: []string{"ruff", "check", "--fix", "./src"}, + }, + }, + ruffFormat{ + Tool: taskutil.Tool{ + RunArgs: []string{"ruff", "format", "./src"}, + }, + }, + pydoclint{ + Tool: taskutil.Tool{ + RunArgs: []string{"uvx", "pydoclint", "./src"}, + }, + }, + mypy{ + Tool: taskutil.Tool{ + RunArgs: []string{"uvx", "mypy", "./src"}, + }, + }, + } + } + + return nil +} + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t buildTask) InfoText() string { return "Build" } + +// Run implements [taskutil.Tasker.Run]. +func (t buildTask) Exec(ctx context.Context) error { + if _, err := taskutil.RunCommand(ctx, t.RunArgs); err != nil { + return err + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t buildTask) Post(_ context.Context) error { return nil } + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t ruffLint) InfoText() string { return "Lint (ruff)" } + +// Run implements [taskutil.Tasker.Run]. +func (t ruffLint) Exec(ctx context.Context) error { + if _, err := taskutil.RunCommand(ctx, t.RunArgs); err != nil { + return err + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t ruffLint) Post(_ context.Context) error { return nil } + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t ruffFormat) InfoText() string { return "Format (ruff)" } + +// Run implements [taskutil.Tasker.Run]. +func (t ruffFormat) Exec(ctx context.Context) error { + if _, err := taskutil.RunCommand(ctx, t.RunArgs); err != nil { + return err + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t ruffFormat) Post(_ context.Context) error { return nil } + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t pydoclint) InfoText() string { return "Lint (pydoclint)" } + +// Run implements [taskutil.Tasker.Run]. +func (t pydoclint) Exec(ctx context.Context) error { + if _, err := taskutil.RunCommand(ctx, t.RunArgs); err != nil { + return err + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t pydoclint) Post(_ context.Context) error { return nil } + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t mypy) InfoText() string { return "Type-check (mypy)" } + +// Run implements [taskutil.Tasker.Run]. +func (t mypy) Exec(ctx context.Context) error { + if _, err := taskutil.RunCommand(ctx, t.RunArgs); err != nil { + return err + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t mypy) Post(_ context.Context) error { return nil } diff --git a/internal/tasks/tools/python/doc.go b/internal/tasks/tools/python/doc.go new file mode 100644 index 0000000..38caea9 --- /dev/null +++ b/internal/tasks/tools/python/doc.go @@ -0,0 +1,2 @@ +// Package pytools contains logic for running tasks for Python. +package pytools diff --git a/internal/tasks/tools/shell/ci.go b/internal/tasks/tools/shell/ci.go new file mode 100644 index 0000000..fa2d5d7 --- /dev/null +++ b/internal/tasks/tools/shell/ci.go @@ -0,0 +1,70 @@ +package shtools + +import ( + "context" + + taskutil "github.com/opensourcecorp/oscar/internal/tasks/util" +) + +type ( + shellcheck struct{ taskutil.Tool } + shfmt struct{ taskutil.Tool } +) + +// NewTasksForCI returns the list of CI tasks. +func NewTasksForCI(repo taskutil.Repo) []taskutil.Tasker { + if repo.HasShell { + return []taskutil.Tasker{ + shellcheck{ + Tool: taskutil.Tool{ + RunArgs: []string{"bash", "-c", ` + shopt -s globstar + ls **/*.sh || exit 0 + shellcheck **/*.sh`, + }, + }, + }, + shfmt{ + Tool: taskutil.Tool{ + RunArgs: []string{"bash", "-c", ` + shopt -s globstar + ls **/*.sh || exit 0 + shfmt **/*.sh`, + }, + }, + }, + } + } + + return nil +} + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t shellcheck) InfoText() string { return "Lint (shellcheck)" } + +// Run implements [taskutil.Tasker.Run]. +func (t shellcheck) Exec(ctx context.Context) error { + if _, err := taskutil.RunCommand(ctx, t.RunArgs); err != nil { + return err + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t shellcheck) Post(_ context.Context) error { return nil } + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t shfmt) InfoText() string { return "Format (shfmt)" } + +// Run implements [taskutil.Tasker.Run]. +func (t shfmt) Exec(ctx context.Context) error { + if _, err := taskutil.RunCommand(ctx, t.RunArgs); err != nil { + return err + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t shfmt) Post(_ context.Context) error { return nil } diff --git a/internal/tasks/tools/shell/doc.go b/internal/tasks/tools/shell/doc.go new file mode 100644 index 0000000..be333a6 --- /dev/null +++ b/internal/tasks/tools/shell/doc.go @@ -0,0 +1,2 @@ +// Package shtools contains logic for running tasks for Shell languages. +package shtools diff --git a/internal/ci/configfiles/.markdownlint-cli2.yaml b/internal/tasks/tools/toolcfg/.markdownlint-cli2.yaml similarity index 95% rename from internal/ci/configfiles/.markdownlint-cli2.yaml rename to internal/tasks/tools/toolcfg/.markdownlint-cli2.yaml index ca080cd..7259a38 100644 --- a/internal/ci/configfiles/.markdownlint-cli2.yaml +++ b/internal/tasks/tools/toolcfg/.markdownlint-cli2.yaml @@ -1,3 +1,4 @@ +--- config: default: true MD013: diff --git a/internal/tasks/tools/toolcfg/.yamlfmt b/internal/tasks/tools/toolcfg/.yamlfmt new file mode 100644 index 0000000..e350023 --- /dev/null +++ b/internal/tasks/tools/toolcfg/.yamlfmt @@ -0,0 +1,12 @@ +--- +# Rules found at: +formatter: + type: "basic" + eof_newline: true + force_array_style: "block" + include_document_start: true + indent: 2 + line_ending: "lf" + max_line_length: 100 + retain_line_breaks_single: true + trim_trailing_whitespace: true diff --git a/internal/tasks/tools/toolcfg/.yamllint b/internal/tasks/tools/toolcfg/.yamllint new file mode 100644 index 0000000..f487d55 --- /dev/null +++ b/internal/tasks/tools/toolcfg/.yamllint @@ -0,0 +1,25 @@ +--- +extends: "default" +# Rules found at: https://yamllint.readthedocs.io/en/stable/rules.html +rules: + comments: + require-starting-space: true + ignore-shebangs: true + min-spaces-from-content: 1 + level: "error" + comments-indentation: "disable" + empty-lines: + max: 1 + max-start: 0 + max-end: 0 + level: "error" + line-length: + max: 100 + level: "error" + quoted-strings: + required: true + quote-type: "double" + level: "error" + truthy: + check-keys: false # mostly to allow GitHub Actions' "on" key to pass + level: "error" diff --git a/internal/tasks/tools/toolcfg/doc.go b/internal/tasks/tools/toolcfg/doc.go new file mode 100644 index 0000000..e7bd20e --- /dev/null +++ b/internal/tasks/tools/toolcfg/doc.go @@ -0,0 +1,3 @@ +// Package toolcfg is used for storing embeddable config files for various tools, that are injected +// at runtime. +package toolcfg diff --git a/internal/tasks/tools/toolcfg/embed.go b/internal/tasks/tools/toolcfg/embed.go new file mode 100644 index 0000000..3641dd9 --- /dev/null +++ b/internal/tasks/tools/toolcfg/embed.go @@ -0,0 +1,8 @@ +package toolcfg + +import "embed" + +// Files stores config files for each CI tool. +// +//go:embed * +var Files embed.FS diff --git a/internal/tasks/tools/toolcfg/hadolint.yaml b/internal/tasks/tools/toolcfg/hadolint.yaml new file mode 100644 index 0000000..f6cc8f3 --- /dev/null +++ b/internal/tasks/tools/toolcfg/hadolint.yaml @@ -0,0 +1,9 @@ +--- +failure-threshold: "style" +# Rules found at: https://github.com/hadolint/hadolint?tab=readme-ov-file#rules +ignored: + - "DL3008" + - "DL3018" + - "DL3033" + - "DL3037" + - "DL3041" diff --git a/internal/ci/configfiles/pyproject.toml b/internal/tasks/tools/toolcfg/pyproject.toml similarity index 100% rename from internal/ci/configfiles/pyproject.toml rename to internal/tasks/tools/toolcfg/pyproject.toml diff --git a/internal/ci/configfiles/revive.toml b/internal/tasks/tools/toolcfg/revive.toml similarity index 100% rename from internal/ci/configfiles/revive.toml rename to internal/tasks/tools/toolcfg/revive.toml diff --git a/internal/tasks/tools/toolcfg/staticcheck.conf b/internal/tasks/tools/toolcfg/staticcheck.conf new file mode 100644 index 0000000..2edb311 --- /dev/null +++ b/internal/tasks/tools/toolcfg/staticcheck.conf @@ -0,0 +1,7 @@ +# All checks available here: +# https://staticcheck.dev/docs/checks/ +checks = [ + "all", + # staticcheck won't ignore generated code for this one, so revive handles this check instead + "-ST1000" +] diff --git a/internal/tasks/tools/toolcfg/util.go b/internal/tasks/tools/toolcfg/util.go new file mode 100644 index 0000000..a04217f --- /dev/null +++ b/internal/tasks/tools/toolcfg/util.go @@ -0,0 +1,24 @@ +package toolcfg + +import ( + "fmt" + "os" + "path/filepath" + + taskutil "github.com/opensourcecorp/oscar/internal/tasks/util" +) + +// SetupConfigFile handles reading a Tool's config file from the embedded filesystem, and writing it +// to its target location. +func SetupConfigFile(t taskutil.Tool) error { + cfgFileContents, err := Files.ReadFile(filepath.Base(t.ConfigFilePath)) + if err != nil { + return fmt.Errorf("reading embedded file contents: %w", err) + } + + if err := os.WriteFile(t.ConfigFilePath, cfgFileContents, 0644); err != nil { + return fmt.Errorf("writing config file: %w", err) + } + + return nil +} diff --git a/internal/tasks/tools/version/ci.go b/internal/tasks/tools/version/ci.go new file mode 100644 index 0000000..22765f5 --- /dev/null +++ b/internal/tasks/tools/version/ci.go @@ -0,0 +1,104 @@ +package versiontools + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + + "github.com/opensourcecorp/oscar/internal/consts" + "github.com/opensourcecorp/oscar/internal/oscarcfg" + iprint "github.com/opensourcecorp/oscar/internal/print" + taskutil "github.com/opensourcecorp/oscar/internal/tasks/util" +) + +type versionCI struct{ taskutil.Tool } + +// NewTasksForCI returns the list of CI tasks. +func NewTasksForCI(_ taskutil.Repo) []taskutil.Tasker { + return []taskutil.Tasker{ + versionCI{}, + } +} + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t versionCI) InfoText() string { return "Versioning checks" } + +// Exec implements [taskutil.Tasker.Exec]. +func (t versionCI) Exec(ctx context.Context) (err error) { + cfg, err := oscarcfg.Get() + if err != nil { + return fmt.Errorf("getting oscar config: %w", err) + } + version := cfg.GetVersion() + iprint.Debugf("provided version: %s\n", version) + + // NOTE: we clone the repo in question to a temp location to check the version on + // the main branch, instead of e.g. trying to checkout main. This is for may reasons, not the + // least of which being that alternatives would be unreliable in e.g. GitHub Actions CI based on + // how it treats PR checkouts et al. A small price to pay for reliability. + tmpCloneDir := filepath.Join(os.TempDir(), "oscar-ci", "this-repo") + if err := os.MkdirAll(filepath.Dir(tmpCloneDir), 0755); err != nil { + return fmt.Errorf("creating temp clone parent directory: %w", err) + } + defer func() { + if rmErr := os.RemoveAll(tmpCloneDir); rmErr != nil { + err = errors.Join(err, fmt.Errorf("removing temp clone directory: %w", rmErr)) + } + }() + + remote, err := taskutil.RunCommand(ctx, []string{"git", "remote", "get-url", "origin"}) + if err != nil { + return fmt.Errorf("determining git root: %w", err) + } + + remote = canonicalizeGitRemote(remote) + + if _, err := taskutil.RunCommand(ctx, []string{"git", "clone", "--depth", "1", remote, tmpCloneDir}); err != nil { + return fmt.Errorf("cloning repo source to temp location: %w", err) + } + + mainCfg, err := oscarcfg.Get(filepath.Join(tmpCloneDir, consts.DefaultOscarCfgFileName)) + if err != nil { + return fmt.Errorf("getting oscar config: %w", err) + } + mainVersion := mainCfg.GetVersion() + iprint.Debugf("main version: %s\n", version) + + // Need to check if we're already on the main branch, since checking its version against itself + // will unintentionally fail + // + // TODO: update internal git package to have a type with ALL this info so I stop copy-pasting + // shell-outs around + branch, err := taskutil.RunCommand(ctx, []string{"git", "rev-parse", "--abbrev-ref", "HEAD"}) + if err != nil { + return fmt.Errorf("checking current Git branch/ref: %w", err) + } + iprint.Debugf("current Git branch/ref: %s\n", branch) + + if branch != "main" { + if !oscarcfg.VersionHasBeenIncremented(version, mainVersion) { + return fmt.Errorf( + "version in oscar config on this branch (%s) has not been incremented from the version on the main branch", + version, + ) + } + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t versionCI) Post(_ context.Context) error { return nil } + +// canonicalizeGitRemote converts a Git remote string to be in canonical HTTPS format. +func canonicalizeGitRemote(remote string) string { + gitSSHRemoteRegex := regexp.MustCompile(`^(https://|git@)(.*)(:|/)(.*)/(.*(.git)?)$`) + groups := gitSSHRemoteRegex.FindStringSubmatch(remote) + + out := fmt.Sprintf("https://%s/%s/%s", groups[2], groups[4], groups[5]) + + return out +} diff --git a/internal/tasks/tools/version/ci_test.go b/internal/tasks/tools/version/ci_test.go new file mode 100644 index 0000000..e8ab971 --- /dev/null +++ b/internal/tasks/tools/version/ci_test.go @@ -0,0 +1,14 @@ +package versiontools + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCanonicalizeGitRemote(t *testing.T) { + remote := "git@github.com:opensourcecorp/oscar.git" + want := "https://github.com/opensourcecorp/oscar.git" + got := canonicalizeGitRemote(remote) + assert.Equal(t, want, got) +} diff --git a/internal/tasks/tools/version/doc.go b/internal/tasks/tools/version/doc.go new file mode 100644 index 0000000..759dafd --- /dev/null +++ b/internal/tasks/tools/version/doc.go @@ -0,0 +1,2 @@ +// Package versiontools contains logic for running tasks against the codebase version identifier(s). +package versiontools diff --git a/internal/tasks/tools/yaml/ci.go b/internal/tasks/tools/yaml/ci.go new file mode 100644 index 0000000..6f8b3ea --- /dev/null +++ b/internal/tasks/tools/yaml/ci.go @@ -0,0 +1,86 @@ +package yamltools + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/opensourcecorp/oscar/internal/tasks/tools/toolcfg" + taskutil "github.com/opensourcecorp/oscar/internal/tasks/util" +) + +type ( + yamlfmt struct{ taskutil.Tool } + yamllint struct{ taskutil.Tool } +) + +// NewTasksForCI returns the list of CI tasks. +func NewTasksForCI(repo taskutil.Repo) []taskutil.Tasker { + if repo.HasYaml { + return []taskutil.Tasker{ + yamlfmt{ + Tool: taskutil.Tool{ + RunArgs: []string{"bash", "-c", + fmt.Sprintf( + `yamlfmt -conf {{ConfigFilePath}} $(%s)`, + taskutil.GetFileTypeListerCommand("yaml"), + ), + }, + ConfigFilePath: filepath.Join(os.TempDir(), ".yamlfmt"), + }, + }, + yamllint{ + Tool: taskutil.Tool{ + RunArgs: []string{"bash", "-c", + fmt.Sprintf( + `yamllint --strict --config-file {{ConfigFilePath}} $(%s)`, + taskutil.GetFileTypeListerCommand("yaml"), + ), + }, + ConfigFilePath: filepath.Join(os.TempDir(), ".yamllint"), + }, + }, + } + } + + return nil +} + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t yamllint) InfoText() string { return "Lint (yamllint)" } + +// Run implements [taskutil.Tasker.Run]. +func (t yamllint) Exec(ctx context.Context) error { + if err := toolcfg.SetupConfigFile(t.Tool); err != nil { + return err + } + + if _, err := taskutil.RunCommand(ctx, t.RenderRunCommandArgs()); err != nil { + return err + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t yamllint) Post(_ context.Context) error { return nil } + +// InfoText implements [taskutil.Tasker.InfoText]. +func (t yamlfmt) InfoText() string { return "Format (yamlfmt)" } + +// Run implements [taskutil.Tasker.Run]. +func (t yamlfmt) Exec(ctx context.Context) error { + if err := toolcfg.SetupConfigFile(t.Tool); err != nil { + return err + } + + if _, err := taskutil.RunCommand(ctx, t.RenderRunCommandArgs()); err != nil { + return err + } + + return nil +} + +// Post implements [taskutil.Tasker.Post]. +func (t yamlfmt) Post(_ context.Context) error { return nil } diff --git a/internal/tasks/tools/yaml/doc.go b/internal/tasks/tools/yaml/doc.go new file mode 100644 index 0000000..de64d48 --- /dev/null +++ b/internal/tasks/tools/yaml/doc.go @@ -0,0 +1,2 @@ +// Package yamltools contains logic for running tasks for YAML. +package yamltools diff --git a/internal/tasks/util/core_types.go b/internal/tasks/util/core_types.go new file mode 100644 index 0000000..5b9415b --- /dev/null +++ b/internal/tasks/util/core_types.go @@ -0,0 +1,66 @@ +package taskutil + +import ( + "context" + "slices" + "strings" + + iprint "github.com/opensourcecorp/oscar/internal/print" +) + +// Tasker defines the method set for working with metadata for a given CI Task. +type Tasker interface { + // InfoText should return a human-readable display string that describes the task, e.g. "Run + // tests". + InfoText() string + // Exec should perform the actual task's actions. + Exec(ctx context.Context) error + // Post should perform any post-run actions for the task, if necessary. + Post(ctx context.Context) error +} + +// A Tool defines information about a tool used for running oscar's tasks. A Tool should be defined +// if a language etc. cannot perform the task itself. For example, you would not need a Tool to +// represent a task that runs "go test", but you *would* need a tool to represent a task that runs +// the external "staticcheck" linter for Go. +type Tool struct { + // The list of command & arguments to run during [Tasker.Exec]. + RunArgs []string + // The path to the tool's config file, if it has one to use. + ConfigFilePath string +} + +// RenderRunCommandArgs uses [Tool.RunArgs] and does naive templating to replace certain values +// before being used. +// +// This is useful because when instantiating [Tasker]s, sometimes the fields need to be +// self-referential within the struct. For example, if a [Tool.RunArgs] needs to specify a config +// file path, but it can't know that value until instantiation (even though it's likely defined +// right below it in [Tool.ConfigFilePath]), you can write the `RunArgs` to have a +// `{{ConfigFilePath}}` placeholder that will be interpolated when calling this function. +func (t Tool) RenderRunCommandArgs() []string { + out := make([]string, len(t.RunArgs)) + for i, arg := range t.RunArgs { + out[i] = strings.ReplaceAll(arg, `{{ConfigFilePath}}`, t.ConfigFilePath) + } + + iprint.Debugf("RenderRunCommandArgs: %#v\n", out) + + return out +} + +// TaskMap aliases a map of a Task's language/tooling name to its list of Tasks. +type TaskMap map[string][]Tasker + +// SortedKeys sorts the keys of the [TaskMap]. Useful for iterating through Tasks in a predictable +// order during runs. +func (tm TaskMap) SortedKeys() []string { + keys := make([]string, 0) + for key := range tm { + keys = append(keys, key) + } + + slices.Sort(keys) + + return keys +} diff --git a/internal/tasks/util/doc.go b/internal/tasks/util/doc.go new file mode 100644 index 0000000..6f05bb9 --- /dev/null +++ b/internal/tasks/util/doc.go @@ -0,0 +1,3 @@ +// Package taskutil contains type definitions and functionality for working with Tasks & Tools +// across the oscar codebase. +package taskutil diff --git a/internal/tasks/util/repo.go b/internal/tasks/util/repo.go new file mode 100644 index 0000000..fa0fcd9 --- /dev/null +++ b/internal/tasks/util/repo.go @@ -0,0 +1,110 @@ +package taskutil + +import ( + "context" + "errors" + + iprint "github.com/opensourcecorp/oscar/internal/print" +) + +// A Repo stores information about the contents of the repository being run against. +type Repo struct { + HasGo bool + HasPython bool + HasShell bool + HasTerraform bool + HasContainerfile bool + HasYaml bool + HasMarkdown bool +} + +// String implements the [fmt.Stringer] interface. +func (repo Repo) String() string { + var out string + + out += "The following file types were found in this repo, and tasks will be run against them:\n" + + if repo.HasGo { + out += "- Go\n" + } + if repo.HasPython { + out += "- Python\n" + } + if repo.HasShell { + out += "- Shell (sh, bash, etc.)\n" + } + if repo.HasTerraform { + out += "- Terraform\n" + } + if repo.HasContainerfile { + out += "- Containerfile\n" + } + if repo.HasYaml { + out += "- YAML\n" + } + if repo.HasMarkdown { + out += "- Markdown\n" + } + + // One more newline for padding + out += "\n" + + return out +} + +// NewRepo returns a populated [Repo]. +func NewRepo(ctx context.Context) (Repo, error) { + var errs error + + hasGo, err := filesExistInTree(ctx, GetFileTypeListerCommand("go")) + if err != nil { + errs = errors.Join(errs, err) + } + + hasPython, err := filesExistInTree(ctx, GetFileTypeListerCommand("py")) + if err != nil { + errs = errors.Join(errs, err) + } + + hasShell, err := filesExistInTree(ctx, GetFileTypeListerCommand("sh")) + if err != nil { + errs = errors.Join(errs, err) + } + + hasTerraform, err := filesExistInTree(ctx, GetFileTypeListerCommand("tf")) + if err != nil { + errs = errors.Join(errs, err) + } + + hasContainerfile, err := filesExistInTree(ctx, GetFileTypeListerCommand("containerfile")) + if err != nil { + errs = errors.Join(errs, err) + } + + hasYaml, err := filesExistInTree(ctx, GetFileTypeListerCommand("yaml")) + if err != nil { + errs = errors.Join(errs, err) + } + + hasMarkdown, err := filesExistInTree(ctx, GetFileTypeListerCommand("md")) + if err != nil { + errs = errors.Join(errs, err) + } + + if errs != nil { + return Repo{}, errs + } + + repo := Repo{ + HasGo: hasGo, + HasPython: hasPython, + HasShell: hasShell, + HasTerraform: hasTerraform, + HasContainerfile: hasContainerfile, + HasYaml: hasYaml, + HasMarkdown: hasMarkdown, + } + iprint.Debugf("repo composition: %+v\n", repo) + + return repo, nil +} diff --git a/internal/tasks/util/run.go b/internal/tasks/util/run.go new file mode 100644 index 0000000..8ba05f7 --- /dev/null +++ b/internal/tasks/util/run.go @@ -0,0 +1,84 @@ +package taskutil + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + iprint "github.com/opensourcecorp/oscar/internal/print" +) + +// A Run holds metadata about an instance of an oscar subcommand run, e.g. a list of Tasks for the +// `ci` subcommand. +type Run struct { + // The "type" of Run as an informative string, i.e. "CI", "Deliver", etc. Used in + // banner-printing in [Run.PrintRunTypeBanner]. + Type string + // A timestamp for storing when the overall run started. + StartTime time.Time + // Keeps track of all task failures. + Failures []string +} + +// NewRun returns a populated [Run]. +func NewRun(ctx context.Context, runType string) (Run, error) { + // Kind of wonky, but print the banner first + Run{Type: runType}.PrintRunTypeBanner() + + // Handle system init + if err := InitSystem(ctx); err != nil { + return Run{}, fmt.Errorf("initializing system: %w", err) + } + + return Run{ + Type: runType, + StartTime: time.Now(), + Failures: make([]string, 0), + }, nil +} + +// PrintRunTypeBanner prints a banner about the type of [Run] underway. +func (run Run) PrintRunTypeBanner() { + // this padding accounts for leading text length before the run.Type string + padding := 9 + bannerChar := "#" + fmt.Printf("%s\n", strings.Repeat(bannerChar, len(run.Type)+padding)) + fmt.Printf("%s Run: %s #\n", bannerChar, run.Type) + fmt.Printf("%s\n\n", strings.Repeat(bannerChar, len(run.Type)+padding)) +} + +// PrintTaskMapBanner prints a banner about the [TaskMap] being run. +func (run Run) PrintTaskMapBanner(lang string) { + fmt.Printf( + "=== %s %s>\n", + lang, strings.Repeat("=", 64-len(lang)), + ) +} + +// PrintTaskBanner prints a banner about the Task being run. +func (run Run) PrintTaskBanner(task Tasker) { + // NOTE: no trailing newline on purpose + fmt.Printf("> %s %s............", task.InfoText(), strings.Repeat(".", 32-len(task.InfoText()))) +} + +// ReportSuccess prints information about the success of a [Run]. +func (run Run) ReportSuccess() { + fmt.Printf("\nAll tasks succeeded! (%s)\n\n", RunDurationString(run.StartTime)) +} + +// ReportFailure prints information about the failure of a [Run]. It takes an `error` arg in case +// the caller is expecting to return a joined error because of e.g. deferred calls or later-checked +// errors that an outer variable already holds. +func (run Run) ReportFailure(err error) error { + iprint.Errorf("\n%s\n", strings.Repeat("=", 65)) + iprint.Errorf("The following tasks failed: (%s)\n", RunDurationString(run.StartTime)) + for _, f := range run.Failures { + iprint.Errorf("- %s\n", f) + } + iprint.Errorf("%s\n\n", strings.Repeat("=", 65)) + + err = errors.Join(err, errors.New("one or more tasks failed")) + return err +} diff --git a/internal/ci/util/util.go b/internal/tasks/util/system.go similarity index 64% rename from internal/ci/util/util.go rename to internal/tasks/util/system.go index 47cda77..0f729c9 100644 --- a/internal/ci/util/util.go +++ b/internal/tasks/util/system.go @@ -1,6 +1,7 @@ -package ciutil +package taskutil import ( + "context" "errors" "fmt" "io" @@ -13,11 +14,12 @@ import ( "github.com/opensourcecorp/oscar" "github.com/opensourcecorp/oscar/internal/consts" + "github.com/opensourcecorp/oscar/internal/hostinfo" iprint "github.com/opensourcecorp/oscar/internal/print" ) // InitSystem runs setup & checks against the host itself, so that oscar can run. -func InitSystem() error { +func InitSystem(ctx context.Context) error { fmt.Printf("Initializing the host, this might take some time... ") startTime := time.Now() @@ -28,7 +30,7 @@ func InitSystem() error { for _, cmd := range requiredSystemCommands { iprint.Debugf("Running '%v'\n", cmd) - if output, err := exec.Command(cmd[0], cmd[1:]...).CombinedOutput(); err != nil { + if output, err := exec.CommandContext(ctx, cmd[0], cmd[1:]...).CombinedOutput(); err != nil { return fmt.Errorf( "command '%s' possibly not found on PATH, cannot continue (error: %w -- output: %s)", cmd[0], err, string(output), @@ -54,7 +56,7 @@ func InitSystem() error { } } - if err := installMise(); err != nil { + if err := installMise(ctx); err != nil { return fmt.Errorf("installing mise: %w", err) } @@ -68,10 +70,10 @@ func InitSystem() error { } // Init for task runs - if err := RunCommand([]string{consts.MiseBinPath, "trust", consts.MiseConfigFileName}); err != nil { + if _, err := RunCommand(ctx, []string{consts.MiseBinPath, "trust", consts.MiseConfigFileName}); err != nil { return fmt.Errorf("running mise trust: %w", err) } - if err := RunCommand([]string{consts.MiseBinPath, "install"}); err != nil { + if _, err := RunCommand(ctx, []string{consts.MiseBinPath, "install"}); err != nil { return fmt.Errorf("running mise install: %w", err) } @@ -80,11 +82,12 @@ func InitSystem() error { return nil } -// RunCommand takes a string slice containing an entire command & its args to run, and returns a -// consisten error message in case of failure. -func RunCommand(cmdArgs []string) error { +// RunCommand takes a string slice containing a command & its args to run, and returns a consistent +// error message in case of failure. It also returns the command output, in case the caller needs to +// parse it on their own. +func RunCommand(ctx context.Context, cmdArgs []string) (string, error) { if len(cmdArgs) <= 1 { - return fmt.Errorf("internal error: not enough arguments passed to RunCommand() -- received: %v", cmdArgs) + return "", fmt.Errorf("internal error: not enough arguments passed to RunCommand() -- received: %v", cmdArgs) } var args []string @@ -94,66 +97,47 @@ func RunCommand(cmdArgs []string) error { args = slices.Concat([]string{"exec", "--"}, cmdArgs) } - cmd := exec.Command(consts.MiseBinPath, args...) + cmd := exec.CommandContext(ctx, consts.MiseBinPath, args...) iprint.Debugf("Running '%v'\n", cmd.Args) - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf( + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf( "running '%v': %w, with output:\n%s", cmd.Args, err, string(output), ) } - return nil + return strings.TrimSuffix(string(output), "\n"), nil } -// GetRepoComposition returns a populated [Repo]. -func GetRepoComposition() (Repo, error) { - var errs error - - hasGo, err := filesExistInTree(`ls **/*.go`) - if err != nil { - errs = errors.Join(errs, err) - } - - hasPython, err := filesExistInTree(`find . -type f -name '*.py' -or -name '*.pyi'`) - if err != nil { - errs = errors.Join(errs, err) - } - - hasShell, err := filesExistInTree(`find . -type f -name '*.*sh' -or -name '*.bats'`) - if err != nil { - errs = errors.Join(errs, err) - } - - hasMarkdown, err := filesExistInTree(`ls **/*.md`) - if err != nil { - errs = errors.Join(errs, err) - } - - if errs != nil { - return Repo{}, errs - } - - repo := Repo{ - HasGo: hasGo, - HasPython: hasPython, - HasShell: hasShell, - HasMarkdown: hasMarkdown, - } - iprint.Debugf("repo composition: %+v\n", repo) - - return repo, nil -} - -// RunDurationString returns a calculated duration used to indicate how long a particular task took -// to run. +// RunDurationString returns a calculated duration used to indicate how long a particular Task (or +// set of Tasks) took to run. func RunDurationString(t time.Time) string { return fmt.Sprintf("t: %s", time.Since(t).Round(time.Second/1000).String()) } -// installMise determines if mise needs to be installed on the host, and if so, installs it into -// [consts.OscarHomeBin]. -func installMise() (err error) { +// GetFileTypeListerCommand takes a [ripgrep]-known file type, and returns a string (to be used as +// an arg after `bash -c`) representing a command & its args to find matching files. ripgrep is used +// as the default file-finder across the codebase because not only is it fast, but it also supports +// files like `.gitignore` without any extra configuration. +// +// [ripgrep]: https://github.com/BurntSushi/ripgrep +func GetFileTypeListerCommand(fileType string) string { + // NOTE: there are some special cases we need to handle, like how ripgrep understands "docker" + // as a file type arg (and it will find files matching the glob "*Dockerfile*"), but it will + // *not* find e.g. "Containerfile" + switch fileType { + case "containerfile": + return `rg --hidden --files --glob-case-insensitive --glob='*{Containerfile,Dockerfile}*' || true` + default: + return fmt.Sprintf(`rg --hidden --files --type '%s' || true`, fileType) + } +} + +// installMise installs [mise] into [consts.OscarHomeBin], if not found there. +// +// [mise]: https://mise.jdx.dev +func installMise(_ context.Context) (err error) { miseFound := true _, err = os.Stat(consts.MiseBinPath) if err != nil { @@ -167,6 +151,7 @@ func installMise() (err error) { } if miseFound { + // TODO: mise version check iprint.Debugf("mise found, nothing to do\n") return } @@ -176,14 +161,14 @@ func installMise() (err error) { miseVersion = consts.MiseVersion } - hostInput := HostInfoInput{ + hostInput := hostinfo.Input{ KernelLinux: "linux", KernelMacOS: "macos", ArchAMD64: "x64", ArchARM64: "arm64", } - host, err := GetHostInfo(hostInput) + host, err := hostinfo.Get(hostInput) if err != nil { return fmt.Errorf("getting host info during mise install: %w", err) } @@ -203,6 +188,7 @@ func installMise() (err error) { } }() + // TODO: use a context func instead resp, err := http.Get(miseReleaseURL) if err != nil { return fmt.Errorf("making GET request for mise GitHub Release: %w", err) @@ -230,11 +216,12 @@ func installMise() (err error) { // filesExistInTree performs file discovery by allowing various tools to check if they need to run // based on file presence. -func filesExistInTree(findScript string) (bool, error) { - cmd := exec.Command("bash", "-c", fmt.Sprintf(` +func filesExistInTree(ctx context.Context, findScript string) (bool, error) { + cmd := exec.CommandContext(ctx, "bash", "-c", fmt.Sprintf(` shopt -s globstar - %s - `, findScript)) + %s`, + findScript, + )) output, err := cmd.CombinedOutput() if err != nil { // If no files found, that's fine, just report it diff --git a/mise.toml b/mise.toml index 5445be7..d1358f7 100644 --- a/mise.toml +++ b/mise.toml @@ -1,9 +1,33 @@ +[settings] +experimental = true +jobs = 4 + [tools] -bats = "v1.12.0" -go = "1.25.0" -markdownlint-cli2 = "v0.18.1" -# node is required for markdownlint-cli2 -node = "24.7.0" -shellcheck = "v0.11.0" -shfmt = "v3.12.0" -uv = "0.8.14" +buf = "1.57.2" +github-cli = "2.79.0" +go = "1.25.1" +hadolint = "2.13.1" +markdownlint-cli2 = "0.18.1" +# required for markdownlint-cli2 +node = "24.8.0" +protobuf = "32.1" +python = "3.13.7" +ripgrep = "14.1.1" +shellcheck = "0.11.0" +shfmt = "3.12.0" +terraform = "1.13.3" +uv = "0.8.18" +yamlfmt = "0.17.2" +yamllint = "1.37.1" + +### Go-specific tools, for oscar or its development +"go:github.com/kisielk/errcheck" = { version = "1.9.0" } +"go:github.com/mgechev/revive" = { version = "1.12.0" } +"go:golang.org/x/tools/cmd/goimports" = { version = "0.37.0" } +"go:golang.org/x/vuln/cmd/govulncheck" = { version = "1.1.4" } +"go:google.golang.org/protobuf/cmd/protoc-gen-go" = { version = "1.36.8" } +"go:honnef.co/go/tools/cmd/staticcheck" = { version = "2025.1.1" } + +### Python-specific tools. Note that others that may also be CLI tools, may be run internally via +### `uvx` instead. +ruff = "0.13.1" diff --git a/oscar.yaml b/oscar.yaml index ae3e08a..e49dd07 100644 --- a/oscar.yaml +++ b/oscar.yaml @@ -1 +1,11 @@ -version: "0.1.0" +--- +version: "0.2.0" +deliverables: + go_github_release: + build_sources: + - "./cmd/oscar" + draft: false + container_image: + registry: "ghcr.io" + namespace: "opensourcecorp" + name: "oscar" diff --git a/proto/buf.gen.yaml b/proto/buf.gen.yaml new file mode 100644 index 0000000..f120073 --- /dev/null +++ b/proto/buf.gen.yaml @@ -0,0 +1,13 @@ +--- +version: "v2" +clean: true +plugins: + # google.golang.org/protobuf/cmd/protoc-gen-go + - local: "protoc-gen-go" + out: "../internal/generated" + opt: + - "paths=source_relative" + # TODO: implement + # # ../proto/generate.go + # - local: "../build/protoc-gen-oscarcfg" + # out: "../internal/generated" diff --git a/proto/buf.lock b/proto/buf.lock new file mode 100644 index 0000000..bb66131 --- /dev/null +++ b/proto/buf.lock @@ -0,0 +1,6 @@ +# Generated by buf. DO NOT EDIT. +version: v2 +deps: + - name: buf.build/bufbuild/protovalidate + commit: 52f32327d4b045a79293a6ad4e7e1236 + digest: b5:cbabc98d4b7b7b0447c9b15f68eeb8a7a44ef8516cb386ac5f66e7fd4062cd6723ed3f452ad8c384b851f79e33d26e7f8a94e2b807282b3def1cd966c7eace97 diff --git a/proto/buf.yaml b/proto/buf.yaml new file mode 100644 index 0000000..7c0c5ea --- /dev/null +++ b/proto/buf.yaml @@ -0,0 +1,18 @@ +--- +version: "v2" +name: "github.com/opensourcecorp/oscar" +deps: + - "buf.build/bufbuild/protovalidate" +lint: + use: + - "STANDARD" + - "COMMENTS" + except: + - "ENUM_VALUE_PREFIX" + - "SERVICE_SUFFIX" + - "RPC_REQUEST_STANDARD_NAME" + - "RPC_RESPONSE_STANDARD_NAME" + - "RPC_REQUEST_RESPONSE_UNIQUE" +breaking: + use: + - "FILE" diff --git a/proto/generate.go b/proto/generate.go new file mode 100644 index 0000000..71d677f --- /dev/null +++ b/proto/generate.go @@ -0,0 +1,11 @@ +// Package main +package main + +import "fmt" + +//go:generate go build -o ../build/protoc-gen-oscarcfg . +//go:generate buf generate + +func main() { + fmt.Println("not implemented, but this generator should generate a populated oscar.yaml") +} diff --git a/proto/opensourcecorp/oscar/config/v1/config.proto b/proto/opensourcecorp/oscar/config/v1/config.proto new file mode 100644 index 0000000..c918e71 --- /dev/null +++ b/proto/opensourcecorp/oscar/config/v1/config.proto @@ -0,0 +1,55 @@ +syntax = "proto3"; + +package opensourcecorp.oscar.config.v1; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/opensourcecorp/oscar/internal/generated/opensourcecorp/oscar/config/v1;oscarcfgpbv1"; + +// Config defines the top-level structure of oscar's config file. +message Config { + // Version is the version string for the codebase. Must be a valid Semantic Version string. + // + // Example: "1.0.0" + string version = 1 [(buf.validate.field).string.pattern = "^[0-9]+\\.[0-9]+\\.[0-9]+(-[a-zA-Z0-9]+)?(\\+[a-zA-Z0-9]+)?$"]; + // Deliverables is the collection of possible deliverable artifacts. + Deliverables deliverables = 2; +} + +// Deliverables contains a field for each possible deliverable. +message Deliverables { + // See [GoGitHubRelease]. + GoGitHubRelease go_github_release = 1; + // See [ContainerImage]. + ContainerImage container_image = 2; +} + +// GoGitHubRelease defines the arguments necessary to create GitHub Releases for Go binaries. +message GoGitHubRelease { + // The filepaths to the "main" packages to be built. + // + // Example: - "./cmd/oscar" + repeated string build_sources = 1 [(buf.validate.field).required = true]; + // Optionally flags whether the Release should be left in Draft state at create-time. This can + // be useful to set if you want to review the Release contents before actually publishing. + // + // Example: false + bool draft = 2; +} + +// ContainerImage defines the arguments necessary to build & push container image artifacts. +message ContainerImage { + // The target registry provider domain. + // + // Example: "ghcr.io" + string registry = 1 [(buf.validate.field).required = true]; + // The target OCI namespace. + // + // Example: "opensourcecorp" + string namespace = 2 [(buf.validate.field).required = true]; + // The target OCI image name. May contain as many subpaths to the actual image artifact as + // necessary based on the registry, e.g. "my-repo/my-image-group/my-image". + // + // Example: "oscar" + string name = 3 [(buf.validate.field).required = true]; +} diff --git a/scripts/tag-release.sh b/scripts/tag-release.sh new file mode 100755 index 0000000..2d9a3de --- /dev/null +++ b/scripts/tag-release.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +################################################################################ +# This script portably manages tagging of the HEAD commit based on the contents +# of the VERSION file. It performs some heuristic checks to make sure tagging +# will behave as expected. +################################################################################ + +root="$(git rev-parse --show-toplevel)" + +current_git_branch="$(git rev-parse --abbrev-ref HEAD)" +latest_git_tag="$(git tag --list | tail -n1)" +current_listed_version="$(grep -v '#' "${root:-}"/VERSION)" + +if [[ -z "${current_git_branch:-}" ]] ; then + printf 'ERROR: unable to determine current git branch\n' > /dev/stderr + exit 1 +fi +if [[ -z "${latest_git_tag:-}" ]] ; then + printf 'ERROR: unable to determine latest git tag\n' > /dev/stderr + exit 1 +fi +if [[ -z "${current_listed_version:-}" ]] ; then + printf 'ERROR: unable to determine version specified in VERSION file\n' > /dev/stderr + exit 1 +fi + +printf 'Current git branch: %s\n' "${current_git_branch:-}" +printf 'Latest git tag: %s\n' "${latest_git_tag:-}" +printf 'Current version listed in VERSION file: %s\n' "${current_listed_version:-}" + +failures=() + +if [[ "${current_git_branch}" == 'main' ]] ; then + printf 'On main branch, will manage tag checks & creation\n' + + # Fail if we forgot to bump VERSION + if [[ "${latest_git_tag}" == "v${current_listed_version}" ]] ; then + printf 'ERROR: Identifier in VERSION still matches what is tagged on the main branch -- did you forget to update?\n' > /dev/stderr + failures+=('forgot-to-bump-VERSION') + fi + + # Fail if we forgot to bump versions across files + # + # Note the "|| echo ''"s -- this is because grep throws a non-zero exit if it + # can't find a match, and it kills the script + old_git_status="$(git status | grep -i -E 'modified' || echo '')" + make -s bump-versions old_version="${current_listed_version}" > /dev/null + new_git_status="$(git status | grep -i -E 'modified' || echo '')" + if [[ "$(diff <(echo "${old_git_status}") <(echo "${new_git_status}") | wc -l)" -gt 0 ]] ; then + printf 'ERROR: Files modified by version-bump check -- did you forget to update versions across the repo to match VERSION?\n' > /dev/stderr + failures+=('forgot-to-bump-other-versions') + fi + + if [[ "${#failures[@]}" -gt 0 ]] ; then + exit 1 + else + printf 'All checks passed, tagging & pushing new version: %s --> %s\n' "${latest_git_tag}" "${current_listed_version}" + git tag --force "v${current_listed_version}" + git push --tags + fi + +else + printf 'Not on main branch, nothing to do\n' + exit 0 +fi diff --git a/scripts/test-bootstrap.sh b/scripts/test-bootstrap.sh index a57b209..d7bd9ab 100644 --- a/scripts/test-bootstrap.sh +++ b/scripts/test-bootstrap.sh @@ -3,7 +3,7 @@ set -euo pipefail shopt -s globstar _setup() { - cp ./internal/ci/configfiles/pyproject.toml . + cp ./internal/tasks/tools/toolcfg/pyproject.toml . mkdir -p ./src cp -r ./testdata/python/src ./src/test_package rename 's/\.test//g' -- ./src/**