diff --git a/.gitea/workflows/devstar-studio-autotest.yaml b/.gitea/workflows/devstar-studio-autotest.yaml new file mode 100644 index 0000000000..3a45d666c4 --- /dev/null +++ b/.gitea/workflows/devstar-studio-autotest.yaml @@ -0,0 +1,45 @@ +# DevStar 自动化测试工作流定义 +# +# Artifact命名规则: +# 1. ${{ vars.DOCKER_REGISTRY_ADDRESS }}/${{ vars.DOCKER_REPOSITORY_ARTIFACT}}:latest +# e.g., devstar.cn/devstar/devstar-studio:latest +# 2. ${{ vars.DOCKER_REGISTRY_ADDRESS }}/${{ vars.DOCKER_REPOSITORY_ARTIFACT}}:rootless-dev-${{ gitea.sha }} +# e.g., devstar.cn/devstar/devstar-studio:rootless-dev-0047d315a3f73cca0c18c641d24b0347456618d5 +# 其中, +# - rootless 表示非 root 权限容器 +# - dev 表示开发版本 +# - ${{ gitea.sha }} 表示触发 CI Workflow 的 commit SHA +# +# 构建参数设置 +# 点击仓库 > 设置 > Actions > 密钥: +# - ${{ secrets.DOCKER_REGISTRY_USERNAME }}: Docker Registry 用户名 +# - ${{ secrets.DOCKER_REGISTRY_PASSWORD }}: Docker Registry 密码 +# 点击仓库 > 设置 > Actions > 变量: +# - ${{ vars.DOCKER_REGISTRY_ADDRESS }}: Docker Registry 域名, e.g., `devstar.cn` +# - ${{ vars.DOCKER_REPOSITORY_ARTIFACT}}: 制品名称, e.g., `devstar/devstar-studio` +# + +name: DevStar Studio Auto Test Pipeline +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build-and-push-x86-64-docker-image: + # Actual runs-on image: docker.io/library/gitea/runner_image:ubuntu-latest + runs-on: ubuntu-latest + steps: + - name: 🔍 Check out repository code + uses: https://devstar.cn/actions/checkout@v4 + with: + ref: main + - name: 🔧 Test Codes and Build an Artifact + run: | + echo "Prepare to build repository code ${{ gitea.repository }}:${{ gitea.ref }}." + make test + make devstar + \ No newline at end of file diff --git a/.gitea/workflows/devstar-studio-dev-ci.yaml b/.gitea/workflows/devstar-studio-ci-cd.yaml similarity index 92% rename from .gitea/workflows/devstar-studio-dev-ci.yaml rename to .gitea/workflows/devstar-studio-ci-cd.yaml index 1ff2b1c630..44b2b53395 100644 --- a/.gitea/workflows/devstar-studio-dev-ci.yaml +++ b/.gitea/workflows/devstar-studio-ci-cd.yaml @@ -1,5 +1,4 @@ -# devstar-studio-dev-ci.yaml -# DevStar 测试并构建制品 CI 工作流定义 +# DevStar 构建制品 CI/CD 工作流定义 # # Artifact命名规则: # 1. ${{ vars.DOCKER_REGISTRY_ADDRESS }}/${{ vars.DOCKER_REPOSITORY_ARTIFACT}}:latest @@ -20,7 +19,7 @@ # - ${{ vars.DOCKER_REPOSITORY_ARTIFACT}}: 制品名称, e.g., `devstar/devstar-studio` # -name: DevStar Studio CI Pipeline +name: DevStar Studio CI/CD Pipeline on: push: branches: @@ -41,11 +40,11 @@ jobs: - name: 🔧 Test Codes and Build an Artifact run: | echo "Prepare to build repository code ${{ gitea.repository }}:${{ gitea.ref }}." - docker build -t gitea/gitea:latest . + make devstar - name: 🚀 Push Artifact to devstar.cn and docker.io Registry run: | - docker tag gitea/gitea:latest ${{ vars.DOCKER_REGISTRY_ADDRESS }}/${{ vars.DOCKER_REPOSITORY_ARTIFACT}}:rootless-dev-${{ gitea.sha }} - docker tag gitea/gitea:latest ${{ vars.DOCKER_REGISTRY_ADDRESS }}/${{ vars.DOCKER_REPOSITORY_ARTIFACT}}:latest + docker tag devstar-studio:latest ${{ vars.DOCKER_REGISTRY_ADDRESS }}/${{ vars.DOCKER_REPOSITORY_ARTIFACT}}:rootless-dev-${{ gitea.sha }} + docker tag devstar-studio:latest ${{ vars.DOCKER_REGISTRY_ADDRESS }}/${{ vars.DOCKER_REPOSITORY_ARTIFACT}}:latest echo "${{ secrets.DOCKER_REGISTRY_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_REGISTRY_USERNAME }} ${{ vars.DOCKER_REGISTRY_ADDRESS }} --password-stdin docker push ${{ vars.DOCKER_REGISTRY_ADDRESS }}/${{ vars.DOCKER_REPOSITORY_ARTIFACT}}:rootless-dev-${{ gitea.sha }} docker push ${{ vars.DOCKER_REGISTRY_ADDRESS }}/${{ vars.DOCKER_REPOSITORY_ARTIFACT}}:latest diff --git a/Makefile b/Makefile index 4c900cfb93..6b20e7315e 100644 --- a/Makefile +++ b/Makefile @@ -938,6 +938,10 @@ controller-manager-debug: go-check CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)' -o controller-manager-debug modules/k8s/cmd/controller-manager/controller-manager.go +.PHONY: devstar +devstar: + docker build -t devstar-studio:latest -f docker/Dockerfile.devstar . + .PHONY: docker docker: docker build --disable-content-trust=false -t $(DOCKER_REF) . diff --git a/README.md b/README.md index dbb291e64d..3fdb5cf6da 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,8 @@ After building, a binary file named `gitea` will be generated in the root of the Start from Container Image: ``` -make docker -public/assets/install.sh start --image=gitea/gitea:latest +make devstar +public/assets/install.sh start --image=devstar-studio:latest # 查看日志 public/assets/install.sh logs @@ -95,8 +95,8 @@ wsl --install -d Ubuntu-20.04 && wsl --setdefault Ubuntu-20.04 ```bash # download and install go -wget -c https://go.dev/dl/go1.23.3.linux-amd64.tar.gz -sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.23.3.linux-amd64.tar.gz +wget -c https://go.dev/dl/go1.24.6.linux-amd64.tar.gz +sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.24.6.linux-amd64.tar.gz export PATH=$PATH:/usr/local/go/bin go version @@ -127,7 +127,7 @@ git commit -m "commit log" git push ``` -在DevStar Git仓库发起Pull Request,合并代码后会自动触发CI流水线完成容器镜像的构建并上传到devstar.cn/devstar/devstar-studio:latest +在DevStar Git仓库发起Pull Request,合并代码后会自动触发CI流水线完成容器镜像的构建并上传到 mengning997/devstar-studio:latest 和 devstar.cn/devstar/devstar-studio:latest ``` public/assets/install.sh start diff --git a/docker/Dockerfile.devContainer b/docker/Dockerfile.devContainer new file mode 100644 index 0000000000..44430e18c5 --- /dev/null +++ b/docker/Dockerfile.devContainer @@ -0,0 +1,17 @@ +# prepare base dev environment for Gitea + +FROM docker.io/library/golang:1.24-alpine3.22 AS build-env + +# Build deps +RUN apk --no-cache add \ + build-base \ + git \ + icu-data-full \ + nodejs \ + npm \ + && rm -rf /var/cache/apk/* + +# To acquire Gitea dev container: +# $ docker build -t devstar.cn/devstar/devstar-dev-container:latest -f docker/Dockerfile.devContainer . +# $ docker login devstar.cn +# $ docker push devstar.cn/devstar/devstar-dev-container:latest diff --git a/docker/Dockerfile.devstar b/docker/Dockerfile.devstar new file mode 100644 index 0000000000..6c374dd9c2 --- /dev/null +++ b/docker/Dockerfile.devstar @@ -0,0 +1,91 @@ +# Build stage +# FROM docker/Dockerfile.devContainer +FROM devstar.cn/devstar/devstar-dev-container:latest AS build-env + +ARG GOPROXY="https://goproxy.cn" +ENV GOPROXY=${GOPROXY:-direct} + +ARG GITEA_VERSION +ARG TAGS="sqlite sqlite_unlock_notify" +ENV TAGS="bindata timetzdata $TAGS" +ARG CGO_EXTRA_CFLAGS + +#Build deps +# RUN apk --no-cache add \ +# build-base \ +# git \ +# nodejs \ +# npm \ +# && rm -rf /var/cache/apk/* + +# Setup repo +COPY . ${GOPATH}/src/code.gitea.io/gitea +WORKDIR ${GOPATH}/src/code.gitea.io/gitea + +# Checkout version if set +RUN if [ -n "${GITEA_VERSION}" ]; then git checkout "${GITEA_VERSION}"; fi \ + && make clean-all build + +# Begin env-to-ini build +RUN go build contrib/environment-to-ini/environment-to-ini.go + +# Copy local files +COPY docker/rootless /tmp/local + +# Set permissions +RUN chmod 755 /tmp/local/usr/local/bin/docker-entrypoint.sh \ + /tmp/local/usr/local/bin/docker-setup.sh \ + /tmp/local/usr/local/bin/gitea \ + /go/src/code.gitea.io/gitea/gitea \ + /go/src/code.gitea.io/gitea/environment-to-ini + +# FROM docker/Dockerfile.runtimeContainer +FROM devstar.cn/devstar/devstar-runtime-container:latest +LABEL maintainer="contact@mengning.com.cn" + +EXPOSE 2222 3000 + +# RUN apk --no-cache add \ +# bash \ +# ca-certificates \ +# dumb-init \ +# gettext \ +# git \ +# curl \ +# gnupg \ +# openssh-keygen \ +# && rm -rf /var/cache/apk/* + +RUN addgroup \ + -S -g 1000 \ + git && \ + adduser \ + -S -H -D \ + -h /var/lib/gitea/git \ + -s /bin/bash \ + -u 1000 \ + -G git \ + git + +RUN mkdir -p /var/lib/gitea /etc/gitea +RUN chown git:git /var/lib/gitea /etc/gitea + +COPY --from=build-env /tmp/local / +COPY --from=build-env --chown=root:root /go/src/code.gitea.io/gitea/gitea /app/gitea/gitea +COPY --from=build-env --chown=root:root /go/src/code.gitea.io/gitea/environment-to-ini /usr/local/bin/environment-to-ini + +# git:git +USER 1000:1000 +ENV GITEA_WORK_DIR=/var/lib/gitea +ENV GITEA_CUSTOM=/var/lib/gitea/custom +ENV GITEA_TEMP=/tmp/gitea +ENV TMPDIR=/tmp/gitea + +# TODO add to docs the ability to define the ini to load (useful to test and revert a config) +ENV GITEA_APP_INI=/etc/gitea/app.ini +ENV HOME="/var/lib/gitea/git" +VOLUME ["/var/lib/gitea", "/etc/gitea"] +WORKDIR /var/lib/gitea + +ENTRYPOINT ["/usr/bin/dumb-init", "--", "/usr/local/bin/docker-entrypoint.sh"] +CMD [] diff --git a/docker/Dockerfile.runtimeContainer b/docker/Dockerfile.runtimeContainer new file mode 100644 index 0000000000..f04e0d0d30 --- /dev/null +++ b/docker/Dockerfile.runtimeContainer @@ -0,0 +1,24 @@ +# prepare base runtime environment for Gitea + +FROM docker.io/library/alpine:3.22 + +RUN apk --no-cache add \ + bash \ + ca-certificates \ + curl \ + gettext \ + git \ + linux-pam \ + openssh \ + s6 \ + dumb-init \ + sqlite \ + su-exec \ + gnupg \ + docker-cli \ + && rm -rf /var/cache/apk/* + +# To acquire Gitea base runtime container: +# $ docker build -t devstar.cn/devstar/devstar-runtime-container:latest -f docker/Dockerfile.runtimeContainer . +# $ docker login devstar.cn +# $ docker push devstar.cn/devstar/devstar-runtime-container:latest diff --git a/go.mod b/go.mod index 427e82ca13..3216f90985 100644 --- a/go.mod +++ b/go.mod @@ -146,6 +146,32 @@ require ( xorm.io/xorm v1.3.9 ) +require ( + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/distribution/reference v0.5.0 // indirect + github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/docker/go-connections v0.4.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gnostic-models v0.6.9 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect + golang.org/x/term v0.32.0 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect + k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect + sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect +) + require ( cel.dev/expr v0.20.0 // indirect cloud.google.com/go/compute/metadata v0.6.0 // indirect @@ -154,7 +180,6 @@ require ( git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 // indirect github.com/ArtisanCloud/PowerSocialite/v3 v3.0.8 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect - github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/DataDog/zstd v1.5.7 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect @@ -205,8 +230,8 @@ require ( github.com/davidmz/go-pageant v1.0.2 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/docker/docker v24.0.9+incompatible github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect - github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -218,14 +243,9 @@ require ( github.com/go-fed/httpsig v1.1.1-0.20201223112313-55836744818e // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-ini/ini v1.67.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.23.0 // indirect github.com/go-webauthn/x v0.1.20 // indirect github.com/goccy/go-json v0.10.5 // indirect - github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect @@ -235,8 +255,6 @@ require ( github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.23.2 // indirect github.com/google/flatbuffers v25.2.10+incompatible // indirect - github.com/google/gnostic-models v0.6.9 // indirect - github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-tpm v0.9.3 // indirect github.com/gorilla/css v1.0.1 // indirect @@ -312,25 +330,16 @@ require ( go.uber.org/zap/exp v0.3.0 // indirect golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect golang.org/x/mod v0.25.0 // indirect - golang.org/x/term v0.32.0 // indirect golang.org/x/time v0.11.0 // indirect golang.org/x/tools v0.33.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250422160041-2d3770c4ea7f // indirect - gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect - gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect k8s.io/apiextensions-apiserver v0.33.0 // indirect k8s.io/apiserver v0.33.0 // indirect - k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect - k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect - sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect - sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect ) replace github.com/hashicorp/go-version => github.com/6543/go-version v1.3.1 diff --git a/go.sum b/go.sum index 64e2cad49e..e4a11246d9 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,8 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.0 h1:UXT0o77lXQrikd1kgwIPQOUect7EoR/+sbP4wQKdzxM= github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.0/go.mod h1:cTvi54pg19DoT07ekoeMgE/taAwNtCShVeZqA+Iv2xI= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= @@ -251,6 +251,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dimiro1/reply v0.0.0-20200315094148-d0136a4c9e21 h1:PdsjTl0Cg+ZJgOx/CFV5NNgO1ThTreqdgKYiDCMHJwA= github.com/dimiro1/reply v0.0.0-20200315094148-d0136a4c9e21/go.mod h1:xJvkyD6Y2rZapGvPJLYo9dyx1s5dxBEDPa8T3YTuOk0= +github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0= +github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/djherbis/buffer v1.1.0/go.mod h1:VwN8VdFkMY0DCALdY8o00d3IZ6Amz/UNVMWcSaJT44o= github.com/djherbis/buffer v1.2.0 h1:PH5Dd2ss0C7CRRhQCZ2u7MssF+No9ide8Ye71nPHcrQ= github.com/djherbis/buffer v1.2.0/go.mod h1:fjnebbZjCUpPinBRD+TDwXSOeNQ7fPQWLfGQqiAiUyE= @@ -261,6 +263,14 @@ github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55k github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= +github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v24.0.9+incompatible h1:HPGzNmwfLZWdxHqK9/II92pyi1EpYKsAqcl4G0Of9v0= +github.com/docker/docker v24.0.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY= github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= @@ -606,6 +616,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 h1:j2kD3MT1z4PXCiUllUJF9mWUESr9TWKS7iEKsQ/IipM= github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450/go.mod h1:skjdDftzkFALcuGzYSklqYd8gvat6F1gZJ4YPVbkZpM= github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg= @@ -1035,6 +1047,8 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0/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= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= istio.io/api v1.26.3 h1:/TiA7bJi24yBQSgpLy5vHhFkobf4DWS1L+CuUxNk4os= istio.io/api v1.26.3/go.mod h1:DTVGH6CLXj5W8FF9JUD3Tis78iRgT1WeuAnxfTz21Wg= istio.io/client-go v1.26.3 h1:ryF4+Nyz5wDO4mVCzXcm2W+fqbnekY88Z36hTcv5fnw= diff --git a/modules/docker/docker_api.go b/modules/docker/docker_api.go new file mode 100644 index 0000000000..6d161dbb5b --- /dev/null +++ b/modules/docker/docker_api.go @@ -0,0 +1,117 @@ +package docker + +import ( + "context" + "io" + "os" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" +) + +// CreateDockerClient 创建Docker客户端 +func CreateDockerClient(ctx context.Context) (*client.Client, error) { + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return nil, err + } + return cli, nil +} + +// GetDockerSocketPath 获取Docker Socket路径 +func GetDockerSocketPath() (string, error) { + // 检查常见的Docker socket路径 + socketPaths := []string{ + "/var/run/docker.sock", + "/run/podman/podman.sock", + "$HOME/.colima/docker.sock", + "$XDG_RUNTIME_DIR/docker.sock", + "$XDG_RUNTIME_DIR/podman/podman.sock", + `\\.\pipe\docker_engine`, + "$HOME/.docker/run/docker.sock", + } + + for _, path := range socketPaths { + if _, err := os.Stat(path); err == nil { + return path, nil + } + } + + // 如果找不到,返回默认路径 + return "/var/run/docker.sock", nil +} + +// PullImage 拉取Docker镜像 +func PullImage(cli *client.Client, dockerHost, imageName string) error { + ctx := context.Background() + + reader, err := cli.ImagePull(ctx, imageName, types.ImagePullOptions{}) + if err != nil { + return err + } + defer reader.Close() + + // 读取并丢弃输出,确保拉取完成 + _, err = io.Copy(io.Discard, reader) + return err +} + +// CreateAndStartContainer 创建并启动容器 +func CreateAndStartContainer(cli *client.Client, imageName string, cmd []string, env []string, binds []string, ports map[string]string, containerName string) error { + ctx := context.Background() + + // 配置容器 + config := &container.Config{ + Image: imageName, + Env: env, + } + + if cmd != nil { + config.Cmd = cmd + } + + hostConfig := &container.HostConfig{ + Binds: binds, + } + + // 如果有端口映射配置 + if ports != nil && len(ports) > 0 { + // 这里可以根据需要添加端口映射逻辑 + // hostConfig.PortBindings = portBindings + } + + // 创建容器 + resp, err := cli.ContainerCreate(ctx, config, hostConfig, nil, nil, containerName) + if err != nil { + return err + } + + // 启动容器 + return cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}) +} + +// DeleteContainer 停止并删除指定名称的容器 +func DeleteContainer(cli *client.Client, containerName string) error { + ctx := context.Background() + + // 首先尝试停止容器 + timeout := 10 + err := cli.ContainerStop(ctx, containerName, container.StopOptions{ + Timeout: &timeout, + }) + if err != nil { + // 如果容器已经停止或不存在,继续执行删除操作 + // 这里不返回错误,因为我们的目标是删除容器 + } + + // 删除容器 + err = cli.ContainerRemove(ctx, containerName, types.ContainerRemoveOptions{ + Force: true, // 强制删除,即使容器正在运行 + }) + if err != nil { + return err + } + + return nil +} diff --git a/modules/setting/k8s.go b/modules/setting/k8s.go new file mode 100644 index 0000000000..e0306ec67d --- /dev/null +++ b/modules/setting/k8s.go @@ -0,0 +1,14 @@ +package setting + +var K8sConfig = struct { + Enable bool + Url string + Token string +}{} + +func loadK8sSettingsFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("k8s") + K8sConfig.Enable = sec.Key("ENABLE").MustBool(false) + K8sConfig.Url = sec.Key("URL").MustString("") + K8sConfig.Token = sec.Key("TOKEN").MustString("") +} diff --git a/modules/setting/runners.go b/modules/setting/runners.go new file mode 100644 index 0000000000..eb0c7c15aa --- /dev/null +++ b/modules/setting/runners.go @@ -0,0 +1,14 @@ +package setting + +var Runner = struct { + AutoStart bool + Count int + Image string +}{} + +func loadRunnerSettingsFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("runners") + Runner.AutoStart = sec.Key("AUTO_START").MustBool(true) + Runner.Count = sec.Key("RUNNER_COUNT").MustInt(1) + Runner.Image = sec.Key("RUNNER_IMAGE").MustString("devstar.cn/devstar/act_runner:latest") +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index d7748db28f..92fbd23532 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -217,6 +217,8 @@ func LoadSettings() { loadProjectFrom(CfgProvider) loadMimeTypeMapFrom(CfgProvider) loadFederationFrom(CfgProvider) + loadRunnerSettingsFrom(CfgProvider) + loadK8sSettingsFrom(CfgProvider) loadWechatSettingsFrom(CfgProvider) } @@ -225,6 +227,8 @@ func LoadSettingsForInstall() { loadDBSetting(CfgProvider) loadServiceFrom(CfgProvider) loadMailerFrom(CfgProvider) + loadRunnerSettingsFrom(CfgProvider) + loadK8sSettingsFrom(CfgProvider) loadWechatSettingsFrom(CfgProvider) } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 53470c6d68..6d5d3b3ce9 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -303,6 +303,10 @@ log_root_path = Log Path log_root_path_helper = Log files will be written to this directory. optional_title = Optional Settings +k8s_title = Kubernetes Settings +k8s_enable = Enable Kubernetes +k8s_url = Kubernetes API URL +k8s_token = Kubernetes Token email_title = Email Settings smtp_addr = SMTP Host smtp_port = SMTP Port @@ -3880,6 +3884,8 @@ runners.status.active = Active runners.status.offline = Offline runners.version = Version runners.reset_registration_token = Reset registration token +runners.regist_runner = Register a new runner +runners.regist_runner_success = Register a new runner successfully runners.reset_registration_token_confirm = Would you like to invalidate the current token and generate a new one? runners.regist_runner = Register a new runner runners.regist_runner_success = Register a new runner successfully diff --git a/options/locale/locale_zh-CN.ini b/options/locale/locale_zh-CN.ini index 89ec21139b..e59c1e72a5 100644 --- a/options/locale/locale_zh-CN.ini +++ b/options/locale/locale_zh-CN.ini @@ -298,6 +298,10 @@ log_root_path=日志路径 log_root_path_helper=日志文件将写入此目录。 optional_title=可选设置 +k8s_title = Kubernetes设置 +k8s_enable = 启用 Kubernetes +k8s_url = Kubernetes API 地址 +k8s_token = Kubernetes 访问令牌 email_title=电子邮箱设置 smtp_addr=SMTP 主机地址 smtp_port=SMTP 端口 @@ -3867,6 +3871,8 @@ runners.status.active=启用 runners.status.offline=离线 runners.version=版本 runners.reset_registration_token=重置注册令牌 +runners.regist_runner=启动并注册一个运行器 +runners.regist_runner_success=成功启动并注册一个运行器 runners.reset_registration_token_confirm=是否吊销当前令牌并生成一个新令牌? runners.regist_runner=启动并注册一个运行器 runners.regist_runner_success=成功启动并注册一个运行器 diff --git a/routers/api/v1/appstore_app.go b/routers/api/v1/appstore_app.go index e1f1ff42e2..9aa42f9cfd 100644 --- a/routers/api/v1/appstore_app.go +++ b/routers/api/v1/appstore_app.go @@ -3,7 +3,7 @@ package v1 import ( "net/http" - "code.gitea.io/gitea/modules/appstore" + "code.gitea.io/gitea/services/appstore" "code.gitea.io/gitea/services/context" ) diff --git a/routers/install/install.go b/routers/install/install.go index d154c1f44e..abc5364d50 100644 --- a/routers/install/install.go +++ b/routers/install/install.go @@ -36,6 +36,7 @@ import ( auth_service "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" + runners_service "code.gitea.io/gitea/services/runners" "code.gitea.io/gitea/services/versioned_migration" "gitea.com/go-chi/session" @@ -123,6 +124,10 @@ func Install(ctx *context.Context) { form.AppURL = setting.AppURL form.LogRootPath = setting.Log.RootPath + form.K8sEnable = setting.K8sConfig.Enable + form.K8sUrl = setting.K8sConfig.Url + form.K8sToken = setting.K8sConfig.Token + // E-mail service settings if setting.MailService != nil { form.SMTPAddr = setting.MailService.SMTPAddr @@ -450,6 +455,20 @@ func SubmitInstall(ctx *context.Context) { cfg.Section("wechat").Key("ENABLED_WECHAT_QR_SIGNIN").SetValue("false") } + if form.K8sEnable { + ctx.Data["K8sEnable"] = form.K8sEnable + cfg.Section("k8s").Key("ENABLE").SetValue("true") + cfg.Section("k8s").Key("URL").SetValue(form.K8sUrl) + cfg.Section("k8s").Key("TOKEN").SetValue(form.K8sToken) + } else { + ctx.Data["K8sEnable"] = form.K8sEnable + cfg.Section("k8s").Key("ENABLE").SetValue("false") + } + + cfg.Section("runners").Key("AUTO_START").SetValue("true") + cfg.Section("runners").Key("RUNNER_COUNT").SetValue("1") + cfg.Section("runners").Key("RUNNER_IMAGE").SetValue("devstar.cn/devstar/act_runner:latest") + cfg.Section("openid").Key("ENABLE_OPENID_SIGNIN").SetValue(strconv.FormatBool(form.EnableOpenIDSignIn)) cfg.Section("openid").Key("ENABLE_OPENID_SIGNUP").SetValue(strconv.FormatBool(form.EnableOpenIDSignUp)) cfg.Section("service").Key("DISABLE_REGISTRATION").SetValue(strconv.FormatBool(form.DisableRegistration)) @@ -594,6 +613,8 @@ func SubmitInstall(ctx *context.Context) { } } + runners_service.RegistGlobalRunner(ctx) + setting.ClearEnvConfigKeys() log.Info("First-time run install finished!") InstallDone(ctx) diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index 828ec08a8a..11aa820e67 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "net/http" + "regexp" "slices" "strings" @@ -169,13 +170,14 @@ func Create(ctx *context.Context) { ctx.Data["private"] = getRepoPrivate(ctx) ctx.Data["default_branch"] = setting.Repository.DefaultBranch ctx.Data["repo_template_name"] = ctx.Tr("repo.template_select") - + ctx.Data["devstar_template_name"] = ctx.Tr("repo.template_select") templateID := ctx.FormInt64("template_id") if templateID > 0 { templateRepo, err := repo_model.GetRepositoryByID(ctx, templateID) if err == nil && access_model.CheckRepoUnitUser(ctx, templateRepo, ctxUser, unit.TypeCode) { ctx.Data["repo_template"] = templateID - ctx.Data["repo_template_name"] = templateRepo.Name + ctx.Data["repo_template_name"] = templateRepo.FullName + ctx.Data["devstar_template_name"] = false } } @@ -214,6 +216,79 @@ func handleCreateError(ctx *context.Context, owner *user_model.User, err error, } } +// isValidCommitUrl checks whether a given URL is a valid Git URL with a commit reference. +func isValidCommitUrl(url string) bool { + // 正则表达式用于匹配包含 commit 的 Git URL + regex := `^https?:\/\/([a-zA-Z0-9.-]+)\/([a-zA-Z0-9._-]+)\/([a-zA-Z0-9._-]+)(\/commit\/([a-f0-9]{40}))?$` + + // 编译正则表达式 + pattern, err := regexp.Compile(regex) + if err != nil { + fmt.Println("Invalid regex for commit URL:", err) + return false + } + + // 测试 URL 是否匹配正则表达式 + return pattern.MatchString(url) +} + +// isValidGitPath checks whether a given URL is a valid Git URL ending with .git. +func isValidGitPath(url string) bool { + // 正则表达式用于匹配以 .git 结尾的 Git URL + regexWithGit := `^https?:\/\/([a-zA-Z0-9.-]+)\/([a-zA-Z0-9._-]+)\/([a-zA-Z0-9._-]+)\.git$` + + // 编译正则表达式 + pattern, err := regexp.Compile(regexWithGit) + if err != nil { + fmt.Println("Invalid regex for .git URL:", err) + return false + } + + // 测试 URL 是否匹配正则表达式 + return pattern.MatchString(url) +} + +// ExtractCommitID 从给定的 URL 中提取 commitID +func ExtractCommitID(url string) (string, error) { + // 正则表达式匹配 commit URL 中的 commitID + regex := `^https?:\/\/[a-zA-Z0-9.-]+\/[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+\/commit\/([a-f0-9]{40})$` + + // 编译正则表达式 + commitPattern, err := regexp.Compile(regex) + if err != nil { + return "", err + } + + // 尝试匹配 URL + if commitPattern.MatchString(url) { + // 提取 commitID + matches := commitPattern.FindStringSubmatch(url) + if len(matches) >= 2 { + return matches[1], nil // 返回 commitID + } + } + + // 如果匹配失败,返回错误 + return "", fmt.Errorf("URL does not match expected format: %s", url) +} + +// ReplaceCommitWithGit 将 commit URL 中的 /commit/ 替换为 .git +func ReplaceCommitWithGit(url string) (string, error) { + // 定义正则表达式匹配 URL 中的 /commit/ 部分 + regex := `(/commit/[a-f0-9]{40})$` + + // 编译正则表达式 + commitPattern, err := regexp.Compile(regex) + if err != nil { + return "", err + } + + // 替换为 .git + replacedURL := commitPattern.ReplaceAllString(url, ".git") + + return replacedURL, nil +} + // CreatePost response for creating repository func CreatePost(ctx *context.Context) { createCommon(ctx) @@ -292,6 +367,54 @@ func CreatePost(ctx *context.Context) { }) if err == nil { log.Trace("Repository created [%d]: %s/%s", repo.ID, ctxUser.Name, repo.Name) + + if strings.TrimSpace(repo.DefaultBranch) == "" { + repo.DefaultBranch = setting.Repository.DefaultBranch + } + + if isValidGitPath(form.DevstarTemplate) { + log.Info("init repo from %s", form.DevstarTemplate) + repo, err = repo_service.InitRepoFromGitURL(ctx, ctx.Doer, ctxUser, form.DevstarTemplate, "", repo) + if err != nil { + log.Error("Error initializing repo from DevstarTemplate: %v", err) + return + } + if repo == nil { + log.Error("repo is nil after initializing from DevstarTemplate") + } + } + + if isValidGitPath(form.GitUrlTemplate) { + log.Info("init repo from %s", form.GitUrlTemplate) + repo, err = repo_service.InitRepoFromGitURL(ctx, ctx.Doer, ctxUser, form.GitUrlTemplate, "", repo) + if err != nil { + log.Error("Error initializing repo from GitUrlTemplate: %v", err) + return + } + if repo == nil { + log.Error("repo is nil after initializing from GitUrlTemplate") + } + } + + if isValidCommitUrl(form.GitUrlTemplate) { + log.Info("init repo from %s", form.GitUrlTemplate) + commitID, err := ExtractCommitID(form.GitUrlTemplate) + if err != nil { + log.Error("Error:", err) + } + gitURL, err := ReplaceCommitWithGit(form.GitUrlTemplate) + if err != nil { + log.Error("Error:", err) + } + repo, err = repo_service.InitRepoFromGitURL(ctx, ctx.Doer, ctxUser, gitURL, commitID, repo) + if err != nil { + log.Error("Error initializing repo from GitUrlTemplate (commit): %v", err) + return + } + if repo == nil { + log.Error("repo is nil after initializing from GitUrlTemplate (commit)") + } + } ctx.Redirect(repo.Link()) return } diff --git a/routers/web/shared/actions/runners.go b/routers/web/shared/actions/runners.go index 648f8046a4..aaf06a84b0 100644 --- a/routers/web/shared/actions/runners.go +++ b/routers/web/shared/actions/runners.go @@ -7,6 +7,7 @@ import ( "errors" "net/http" "net/url" + "strings" actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" @@ -18,6 +19,7 @@ import ( shared_user "code.gitea.io/gitea/routers/web/shared/user" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" + runners_services "code.gitea.io/gitea/services/runners" ) const ( @@ -253,6 +255,13 @@ func RunnersEditPost(ctx *context.Context) { runner.Description = form.Description err = actions_model.UpdateRunner(ctx, runner, "description") + agentLabelsStr := ctx.Req.FormValue("agentlabels") + form.AgentLabels = strings.Split(agentLabelsStr, ",") + for i := range form.AgentLabels { + form.AgentLabels[i] = strings.TrimSpace(form.AgentLabels[i]) + } + runner.AgentLabels = form.AgentLabels + err = actions_model.UpdateRunner(ctx, runner, "description", "agent_labels") if err != nil { log.Warn("RunnerDetailsEditPost.UpdateRunner failed: %v, url: %s", err, ctx.Req.URL) ctx.Flash.Warning(ctx.Tr("actions.runners.update_runner_failed")) @@ -285,6 +294,31 @@ func ResetRunnerRegistrationToken(ctx *context.Context) { ctx.JSONRedirect(redirectTo) } +func RegisterARunner(ctx *context.Context) { + rCtx, err := getRunnersCtx(ctx) + if err != nil { + ctx.ServerError("getRunnersCtx", err) + return + } + token, err := actions_model.NewRunnerToken(ctx, rCtx.OwnerID, rCtx.RepoID) + if err != nil { + ctx.ServerError("NewRunnerToken", err) + return + } + regToken := token.Token + requestCtx := ctx.Req.Context() + err = runners_services.RegistRunner(requestCtx, regToken) + if err != nil { + log.Warn("RegistRunner failed: %v, url: %s", err, ctx.Req.URL) + ctx.Flash.Warning(ctx.Tr("actions.runners.regist_runner_failed")) + ctx.Redirect(rCtx.RedirectLink) + return + } + + ctx.Flash.Success(ctx.Tr("actions.runners.regist_runner_success")) + ctx.Redirect(rCtx.RedirectLink) +} + // RunnerDeletePost response for deleting runner func RunnerDeletePost(ctx *context.Context) { rCtx, err := getRunnersCtx(ctx) @@ -306,6 +340,17 @@ func RunnerDeletePost(ctx *context.Context) { successRedirectTo := rCtx.RedirectLink failedRedirectTo := rCtx.RedirectLink + url.PathEscape(ctx.PathParam("runnerid")) + // 删除对应的Docker容器 + if runner.Name != "" { + requestCtx := ctx.Req.Context() + if err := runners_services.DeleteRunnerByName(requestCtx, runner.Name); err != nil { + log.Warn("DeleteRunnerByName failed: %v, runner name: %s, url: %s", err, runner.Name, ctx.Req.URL) + // 即使删除容器失败,我们仍然继续删除数据库记录 + } else { + log.Info("Successfully deleted Docker container for runner: %s", runner.Name) + } + } + if err := actions_model.DeleteRunner(ctx, runner.ID); err != nil { log.Warn("DeleteRunnerPost.UpdateRunner failed: %v, url: %s", err, ctx.Req.URL) ctx.Flash.Warning(ctx.Tr("actions.runners.delete_runner_failed")) diff --git a/routers/web/user/setting/appstore.go b/routers/web/user/setting/appstore.go index e175a3c67a..b71cead293 100644 --- a/routers/web/user/setting/appstore.go +++ b/routers/web/user/setting/appstore.go @@ -9,9 +9,9 @@ import ( "strings" appstore_model "code.gitea.io/gitea/models/appstore" - "code.gitea.io/gitea/modules/appstore" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/services/appstore" "code.gitea.io/gitea/services/context" ) diff --git a/routers/web/web.go b/routers/web/web.go index b24ce09dd6..fafcb35bfe 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -473,6 +473,16 @@ func registerWebRoutes(m *web.Router) { }) } + addSettingsRunnersRegRoutes := func() { + m.Group("/runners", func() { + m.Get("", shared_actions.Runners) + m.Combo("/{runnerid}").Get(shared_actions.RunnersEdit). + Post(web.Bind(forms.EditRunnerForm{}), shared_actions.RunnersEditPost) + m.Post("/{runnerid}/delete", shared_actions.RunnerDeletePost) + m.Get("/regist_runner", shared_actions.RegisterARunner) + }) + } + // FIXME: not all routes need go through same middleware. // Especially some AJAX requests, we can reduce middleware number to improve performance. @@ -668,6 +678,7 @@ func registerWebRoutes(m *web.Router) { m.Group("/actions", func() { m.Get("", user_setting.RedirectToDefaultSetting) addSettingsRunnersRoutes() + addSettingsRunnersRegRoutes() addSettingsSecretsRoutes() addSettingsVariablesRoutes() }, actions.MustEnableActions) @@ -823,6 +834,7 @@ func registerWebRoutes(m *web.Router) { m.Group("/actions", func() { m.Get("", admin.RedirectToDefaultSetting) addSettingsRunnersRoutes() + addSettingsRunnersRegRoutes() addSettingsVariablesRoutes() }) }, adminReq, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled)) @@ -971,6 +983,7 @@ func registerWebRoutes(m *web.Router) { m.Group("/actions", func() { m.Get("", org_setting.RedirectToDefaultSetting) addSettingsRunnersRoutes() + addSettingsRunnersRegRoutes() addSettingsSecretsRoutes() addSettingsVariablesRoutes() }, actions.MustEnableActions) @@ -1163,6 +1176,7 @@ func registerWebRoutes(m *web.Router) { m.Group("/actions", func() { m.Get("", shared_actions.RedirectToDefaultSetting) addSettingsRunnersRoutes() + addSettingsRunnersRegRoutes() addSettingsSecretsRoutes() addSettingsVariablesRoutes() }, actions.MustEnableActions) diff --git a/modules/appstore/examples/nginx.json b/services/appstore/examples/nginx.json similarity index 100% rename from modules/appstore/examples/nginx.json rename to services/appstore/examples/nginx.json diff --git a/modules/appstore/k8s_application_mapper.go b/services/appstore/k8s_application_mapper.go similarity index 100% rename from modules/appstore/k8s_application_mapper.go rename to services/appstore/k8s_application_mapper.go diff --git a/modules/appstore/k8s_manager.go b/services/appstore/k8s_manager.go similarity index 100% rename from modules/appstore/k8s_manager.go rename to services/appstore/k8s_manager.go diff --git a/modules/appstore/manager.go b/services/appstore/manager.go similarity index 100% rename from modules/appstore/manager.go rename to services/appstore/manager.go diff --git a/modules/appstore/parser.go b/services/appstore/parser.go similarity index 100% rename from modules/appstore/parser.go rename to services/appstore/parser.go diff --git a/modules/appstore/types.go b/services/appstore/types.go similarity index 100% rename from modules/appstore/types.go rename to services/appstore/types.go diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index cb267f891c..dd89d6618b 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -32,6 +32,8 @@ type CreateRepoForm struct { Readme string Template bool + DevstarTemplate string + GitUrlTemplate string RepoTemplate int64 GitContent bool Topics bool diff --git a/services/forms/runner.go b/services/forms/runner.go index 6abfc66fc2..9d8d29d126 100644 --- a/services/forms/runner.go +++ b/services/forms/runner.go @@ -15,6 +15,7 @@ import ( // EditRunnerForm form for admin to create runner type EditRunnerForm struct { Description string + AgentLabels []string } // Validate validates form fields diff --git a/services/forms/user_form.go b/services/forms/user_form.go index 5cf4ed67a9..e72df81144 100644 --- a/services/forms/user_form.go +++ b/services/forms/user_form.go @@ -37,6 +37,10 @@ type InstallForm struct { AppURL string `binding:"Required"` LogRootPath string `binding:"Required"` + K8sEnable bool + K8sUrl string + K8sToken string + SMTPAddr string SMTPPort string SMTPFrom string diff --git a/services/repository/create.go b/services/repository/create.go index bed02e5d7e..75fec0a453 100644 --- a/services/repository/create.go +++ b/services/repository/create.go @@ -160,7 +160,7 @@ func initRepository(ctx context.Context, u *user_model.User, repo *repo_model.Re } // Apply changes and commit. - if err = initRepoCommit(ctx, tmpDir, repo, u, opts.DefaultBranch); err != nil { + if err = initRepoCommit(ctx, tmpDir, repo, u, opts.DefaultBranch, "Initial commit"); err != nil { return fmt.Errorf("initRepoCommit: %w", err) } } diff --git a/services/repository/generate.go b/services/repository/generate.go index 867b5d7855..ece4b6d17e 100644 --- a/services/repository/generate.go +++ b/services/repository/generate.go @@ -9,6 +9,7 @@ import ( "context" "fmt" "os" + "path" "path/filepath" "regexp" "strconv" @@ -250,7 +251,7 @@ func generateRepoCommit(ctx context.Context, repo, templateRepo, generateRepo *r defaultBranch = templateRepo.DefaultBranch } - return initRepoCommit(ctx, tmpDir, repo, repo.Owner, defaultBranch) + return initRepoCommit(ctx, tmpDir, repo, repo.Owner, defaultBranch, "Initial commit") } // GenerateGitContent generates git content from a template repository @@ -292,6 +293,81 @@ func GenerateGitContent(ctx context.Context, templateRepo, generateRepo *repo_mo return nil } +// GenerateGitContent generates git content from a Git URL +func GenerateGitContentFromGitURL(ctx context.Context, gitURL, commitID string, repo *repo_model.Repository) error { + tmpDir, err := os.MkdirTemp(os.TempDir(), "gitea-"+repo.Name) + if err != nil { + return fmt.Errorf("Failed to create temp dir for repository %s: %w", gitURL, err) + } + + defer func() { + if err := util.RemoveAll(tmpDir); err != nil { + log.Error("RemoveAll: %v", err) + } + }() + // generateRepoCommit + commitTimeStr := time.Now().Format(time.RFC3339) + authorSig := repo.Owner.NewGitSig() + + // Because this may call hooks we should pass in the environment + env := append(os.Environ(), + "GIT_AUTHOR_NAME="+authorSig.Name, + "GIT_AUTHOR_EMAIL="+authorSig.Email, + "GIT_AUTHOR_DATE="+commitTimeStr, + "GIT_COMMITTER_NAME="+authorSig.Name, + "GIT_COMMITTER_EMAIL="+authorSig.Email, + "GIT_COMMITTER_DATE="+commitTimeStr, + ) + + // Clone to temporary path with a specified depth. + if err := git.Clone(ctx, gitURL, tmpDir, git.CloneRepoOptions{ + Depth: 1, // 仍然可以保持深度克隆以优化性能 + }); err != nil { + return fmt.Errorf("git clone: %w", err) + } + // Change to the specified commit ID after cloning + if commitID != "" { + if err := git.NewCommand("checkout").AddDynamicArguments(commitID).Run(ctx, &git.RunOpts{Dir: tmpDir}); err != nil { + return fmt.Errorf("git checkout %s: %w", commitID, err) + } + } + // Get the current SHA1 version of the working directory + var sha1Buffer bytes.Buffer + if err := git.NewCommand("rev-parse", "HEAD").Run(ctx, &git.RunOpts{ + Dir: tmpDir, + Stdout: &sha1Buffer, // 使用 bytes.Buffer + }); err != nil { + return fmt.Errorf("git rev-parse HEAD: %w", err) + } + // 获取SHA1字符串并修整空白 + sha1 := strings.TrimSpace(sha1Buffer.String()) + + if err := util.RemoveAll(path.Join(tmpDir, ".git")); err != nil { + return fmt.Errorf("remove git dir: %w", err) + } + + if err := git.InitRepository(ctx, tmpDir, false, repo.ObjectFormatName); err != nil { + return err + } + + repoPath := repo.RepoPath() + if stdout, _, err := git.NewCommand("remote", "add", "origin").AddDynamicArguments(repoPath). + //SetDescription(fmt.Sprintf("generateRepoCommit (git remote add): %s to %s", gitURL, tmpDir)). + RunStdString(ctx, &git.RunOpts{Dir: tmpDir, Env: env}); err != nil { + log.Error("Unable to add %v as remote origin to temporary repo to %s: stdout %s\nError: %v", repo, tmpDir, stdout, err) + return fmt.Errorf("git remote add: %w", err) + } + commitMessage := "Initial commit from " + gitURL + " ( " + sha1 + " ) " + initRepoCommit(ctx, tmpDir, repo, repo.Owner, repo.DefaultBranch, commitMessage) + + if err := repo_module.UpdateRepoSize(ctx, repo); err != nil { + return fmt.Errorf("failed to update size for repository: %w", err) + } + + log.Info("GenerateGitContentFromGitURL init repo from %s : %s", gitURL, commitID) + return nil +} + // GenerateRepoOptions contains the template units to generate type GenerateRepoOptions struct { Name string diff --git a/services/repository/init.go b/services/repository/init.go index 1eeeb4aa4f..72f7c6c1b1 100644 --- a/services/repository/init.go +++ b/services/repository/init.go @@ -19,9 +19,9 @@ import ( ) // initRepoCommit temporarily changes with work directory. -func initRepoCommit(ctx context.Context, tmpPath string, repo *repo_model.Repository, u *user_model.User, defaultBranch string) (err error) { +func initRepoCommit(ctx context.Context, tmpPath string, repo *repo_model.Repository, u *user_model.User, defaultBranch string, commitMessage string) (err error) { commitTimeStr := time.Now().Format(time.RFC3339) - + commitMsg := "--message=" + commitMessage sig := u.NewGitSig() // Because this may call hooks we should pass in the environment env := append(os.Environ(), @@ -39,7 +39,7 @@ func initRepoCommit(ctx context.Context, tmpPath string, repo *repo_model.Reposi return fmt.Errorf("git add --all: %w", err) } - cmd := git.NewCommand("commit", "--message=Initial commit"). + cmd := git.NewCommand("commit").AddOptionFormat(commitMsg). AddOptionFormat("--author='%s <%s>'", sig.Name, sig.Email) sign, key, signer, _ := asymkey_service.SignInitialCommit(ctx, tmpPath, u) diff --git a/services/repository/template.go b/services/repository/template.go index 6906a60083..9c462bc528 100644 --- a/services/repository/template.go +++ b/services/repository/template.go @@ -192,3 +192,17 @@ func GenerateRepository(ctx context.Context, doer, owner *user_model.User, templ return generateRepo, nil } + +// InitRepoFromGitURL for a repository from a Git URL +func InitRepoFromGitURL(ctx context.Context, doer, owner *user_model.User, gitURL, commitID string, repo *repo_model.Repository) (_ *repo_model.Repository, err error) { + if !doer.IsAdmin { + return nil, nil + } + + err = GenerateGitContentFromGitURL(ctx, gitURL, commitID, repo) + if err != nil { + return nil, err + } + + return repo, nil +} diff --git a/services/runners/runners.go b/services/runners/runners.go new file mode 100644 index 0000000000..21364d93da --- /dev/null +++ b/services/runners/runners.go @@ -0,0 +1,435 @@ +package runners + +import ( + "context" + "fmt" + "net" + "strings" + "time" + + actions_module "code.gitea.io/gitea/models/actions" + docker_module "code.gitea.io/gitea/modules/docker" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +func RegistGlobalRunner(ctx context.Context) error { + log.Info("获取全局RunnerToken...") + actionRunnerToken, err := actions_module.NewRunnerToken(ctx, 0, 0) + if err != nil { + return fmt.Errorf("获取全局RunnerToken失败:%v", err) + } + runnerCount := setting.Runner.Count + for i := 0; i < runnerCount; i++ { + err := RegistRunner(ctx, actionRunnerToken.Token) + if err != nil { + return fmt.Errorf("注册Runner失败:%v", err) + } + } + return nil +} + +func checkK8sIsEnable() bool { + return setting.K8sConfig.Enable +} + +func RegistRunner(ctx context.Context, token string) error { + log.Info("开始注册Runner...") + var err error + if checkK8sIsEnable() { + err = registK8sRunner(ctx, token) + } else { + err = registDockerRunner(ctx, token) + } + if err != nil { + return fmt.Errorf("注册Runner失败:%v", err) + } + log.Info("Runner注册成功: %s", token) + return nil +} + +func registDockerRunner(ctx context.Context, token string) error { + log.Info("开始注册Runner...") + cli, err := docker_module.CreateDockerClient(ctx) + if err != nil { + return err + } + defer cli.Close() + //拉取act_runner镜像 + dockerHost, err := docker_module.GetDockerSocketPath() + if err != nil { + return fmt.Errorf("获取docker socket路径失败:%v", err) + } + // 拉取镜像 + err = docker_module.PullImage(cli, dockerHost, setting.Runner.Image) + if err != nil { + return fmt.Errorf("拉取act_runner镜像失败:%v", err) + } + //获取本机IP + ips, err := getLocalIP() + if err != nil { + return fmt.Errorf("获取本机IP失败:%v", err) + } + //获取InstanceUrl + conntype := strings.Split(setting.AppURL, "://")[0] + port := setting.HTTPPort + instanceURL := conntype + "://" + ips[0] + ":" + port + timestamp := time.Now().Format("20060102150405") + //Runner配置 + env := []string{ + "GITEA_INSTANCE_URL=" + instanceURL, + "GITEA_RUNNER_REGISTRATION_TOKEN=" + token, + "GITEA_RUNNER_NAME=runner-" + timestamp, + } + binds := []string{ + "/var/run/docker.sock:/var/run/docker.sock", + } + containerName := "runner-" + timestamp + //创建并启动Runner容器 + err = docker_module.CreateAndStartContainer(cli, setting.Runner.Image, nil, env, binds, nil, containerName) + if err != nil { + return fmt.Errorf("创建并注册Runner失败:%v", err) + } + return nil +} + +func DeleteRunnerByName(ctx context.Context, runnerName string) error { + log.Info("开始停止并删除容器: %s", runnerName) + var err error + if checkK8sIsEnable() { + err = deleteK8sRunnerByName(ctx, runnerName) + } else { + err = deleteDockerRunnerByName(ctx, runnerName) + } + if err != nil { + return fmt.Errorf("删除Runner失败:%v", err) + } + log.Info("Runner删除成功: %s", runnerName) + return nil +} + +func deleteDockerRunnerByName(ctx context.Context, runnerName string) error { + log.Info("开始停止并删除容器: %s", runnerName) + + // 创建Docker客户端 + cli, err := docker_module.CreateDockerClient(ctx) + if err != nil { + return fmt.Errorf("Docker client创建失败:%v", err) + } + log.Info("[StopAndRemoveContainer]Docker client创建成功") + defer cli.Close() + err = docker_module.DeleteContainer(cli, runnerName) + if err != nil { + return fmt.Errorf("Runner创建失败:%v", err) + } + return nil +} + +func getLocalIP() ([]string, error) { + var ips []string + interfaces, err := net.Interfaces() + if err != nil { + return nil, err + } + + for _, iface := range interfaces { + if iface.Flags&net.FlagUp == 0 || + iface.Flags&net.FlagLoopback != 0 { + continue + } + + addrs, err := iface.Addrs() + if err != nil { + continue + } + + // 遍历地址列表 + for _, addr := range addrs { + var ip net.IP + switch v := addr.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + } + if ip == nil || ip.IsLoopback() { + continue + } + + ip = ip.To4() + if ip == nil { + continue // 非IPv4地址 + } + + ips = append(ips, ip.String()) + } + } + if len(ips) == 0 { + return nil, fmt.Errorf("no valid IP address found") + } + return ips, nil +} + +func getK8sUrlAndToken() (string, string, error) { + if !checkK8sIsEnable() { + return "", "", fmt.Errorf("K8s未启用") + } + k8sUrl := setting.K8sConfig.Url + k8sToken := setting.K8sConfig.Token + + if k8sUrl == "" || k8sToken == "" { + return "", "", fmt.Errorf("K8s配置不完整") + } + return k8sUrl, k8sToken, nil +} + +func registK8sRunner(ctx context.Context, token string) error { + log.Info("开始注册Kubernetes Runner: %s", token) + k8sURL, k8sToken, err := getK8sUrlAndToken() + if err != nil { + return fmt.Errorf("获取K8s配置失败: %v", err) + } + + // 测试连接 + err = testKubernetesConnection(k8sURL, k8sToken) + if err != nil { + return fmt.Errorf("Kubernetes连接测试失败: %v", err) + } + + // 创建K8s客户端 + clientset, err := createKubernetesClient(k8sURL, k8sToken) + if err != nil { + return fmt.Errorf("创建Kubernetes客户端失败: %v", err) + } + // 获取实例URL + instanceURL, err := getInstanceURL() + if err != nil { + return fmt.Errorf("获取实例URL失败: %v", err) + } + + // 创建Runner Deployment + deployment, err := createRunnerDeployment(token, instanceURL) + if err != nil { + return fmt.Errorf("创建Runner Deployment配置失败: %v", err) + } + + // 部署到Kubernetes + //namespace := setting.K8sConfig.Namespace + var namespace string + if namespace == "" { + namespace = "act-runner" + } + + _, err = clientset.AppsV1().Deployments(namespace).Create(ctx, deployment, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("在Kubernetes中创建Runner Deployment失败: %v", err) + } + + log.Info("成功在Kubernetes中创建Runner: %s", deployment.Name) + return nil +} + +func getInstanceURL() (string, error) { + // 如果AppURL是公网地址,直接使用 + if setting.AppURL != "" && + !strings.Contains(setting.AppURL, "127.0.0.1") && + !strings.Contains(setting.AppURL, "localhost") { + log.Info("使用配置的AppURL: %s", setting.AppURL) + return setting.AppURL, nil + } + + // 否则构建URL + ips, err := getLocalIP() + if err != nil { + return "", fmt.Errorf("获取本机IP失败: %v", err) + } + + if len(ips) == 0 { + return "", fmt.Errorf("没有找到有效的IP地址") + } + + // 使用第一个IP构建URL + conntype := "http" + if strings.Contains(setting.AppURL, "https://") { + conntype = "https" + } + + port := setting.HTTPPort + instanceURL := conntype + "://" + ips[0] + ":" + port + + log.Info("构建的实例URL: %s", instanceURL) + return instanceURL, nil +} +func deleteK8sRunnerByName(ctx context.Context, runnerName string) error { + log.Info("开始删除K8s Runner: %s", runnerName) + + // 创建Kubernetes客户端 + clientset, err := createKubernetesClient(setting.K8sConfig.Url, setting.K8sConfig.Token) + if err != nil { + return fmt.Errorf("创建Kubernetes客户端失败: %v", err) + } + + // 设置namespace,与创建时保持一致 + + namespace := "act-runner" + + // 删除Deployment + err = clientset.AppsV1().Deployments(namespace).Delete(ctx, runnerName, metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("删除K8s Runner Deployment失败: %v", err) + } + + log.Info("成功删除K8s Runner Deployment: %s", runnerName) + return nil +} +func createKubernetesClient(k8sURL, token string) (*kubernetes.Clientset, error) { + config := &rest.Config{ + Host: k8sURL, + BearerToken: token, + TLSClientConfig: rest.TLSClientConfig{ + Insecure: true, + }, + } + + // 创建客户端 + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("创建Kubernetes客户端失败: %v", err) + } + + return clientset, nil +} +func testKubernetesConnection(k8sURL, token string) error { + clientset, err := createKubernetesClient(k8sURL, token) + if err != nil { + return err + } + + // 尝试获取节点列表来测试连接 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err = clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{Limit: 1}) + if err != nil { + return fmt.Errorf("无法连接到Kubernetes集群: %v", err) + } + + log.Info("Kubernetes连接测试成功") + return nil +} + +func createRunnerDeployment(token, instanceURL string) (*appsv1.Deployment, error) { + timestamp := time.Now().Format("20060102150405") + name := "act-runner-" + timestamp + + labels := map[string]string{ + "app": "act-runner", + "type": "runner", + "version": "1.0", + } + + // 副本数从配置获取 + replicas := int32(1) + + // 创建Deployment配置 + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: labels, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "act-runner", + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": "act-runner", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "act-runner", // 匹配现有配置中的容器名 + Image: setting.Runner.Image, + Ports: []corev1.ContainerPort{ + { + Name: "http-0", + ContainerPort: 3000, + Protocol: corev1.ProtocolTCP, + }, + }, + Env: []corev1.EnvVar{ + { + Name: "GITEA_INSTANCE_URL", + Value: instanceURL, + }, + { + Name: "GITEA_RUNNER_REGISTRATION_TOKEN", + Value: token, + }, + { + Name: "GITEA_RUNNER_NAME", // 可选:如果需要设置runner名称 + Value: name, + }, + }, + // 移除资源限制以匹配现有配置(现有配置中 resources: {}) + Resources: corev1.ResourceRequirements{}, + // 挂载Docker socket + VolumeMounts: []corev1.VolumeMount{ + { + Name: "docker-sock", + MountPath: "/var/run/docker.sock", + }, + }, + ImagePullPolicy: corev1.PullIfNotPresent, + }, + }, + // Docker socket卷 + Volumes: []corev1.Volume{ + { + Name: "docker-sock", + VolumeSource: corev1.VolumeSource{ + HostPath: &corev1.HostPathVolumeSource{ + Path: "/var/run/docker.sock", + }, + }, + }, + }, + RestartPolicy: corev1.RestartPolicyAlways, + ServiceAccountName: "default", // 匹配现有配置 + DNSPolicy: corev1.DNSClusterFirst, + // 添加节点选择器(如果需要) + NodeSelector: map[string]string{ + "kubernetes.io/hostname": "node1", // 可以从配置中读取 + }, + // 添加容忍度 + Tolerations: []corev1.Toleration{ + { + Key: "node.kubernetes.io/not-ready", + Operator: corev1.TolerationOpExists, + Effect: corev1.TaintEffectNoExecute, + TolerationSeconds: func() *int64 { i := int64(300); return &i }(), + }, + { + Key: "node.kubernetes.io/unreachable", + Operator: corev1.TolerationOpExists, + Effect: corev1.TaintEffectNoExecute, + TolerationSeconds: func() *int64 { i := int64(300); return &i }(), + }, + }, + }, + }, + }, + } + + return deployment, nil +} diff --git a/templates/install.tmpl b/templates/install.tmpl index fea40eaca5..a6ccf49bca 100644 --- a/templates/install.tmpl +++ b/templates/install.tmpl @@ -158,6 +158,27 @@

{{ctx.Locale.Tr "install.optional_title"}}

+ +
+ + {{ctx.Locale.Tr "install.k8s_title"}} + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
diff --git a/templates/repo/create.tmpl b/templates/repo/create.tmpl index ada7e0c092..55f7ca2f32 100644 --- a/templates/repo/create.tmpl +++ b/templates/repo/create.tmpl @@ -61,17 +61,31 @@ + + {{if .devstar_template_name}} +
+ + + {{ctx.Locale.Tr "repo.devstar_template_desc" "https://DevStar.cn/"}} +
+ {{end}} + +
-
+
@@ -113,8 +127,9 @@
+
-
+
+
+ + +
+
diff --git a/templates/shared/actions/runner_list.tmpl b/templates/shared/actions/runner_list.tmpl index 43321a8dc5..9b2b90874c 100644 --- a/templates/shared/actions/runner_list.tmpl +++ b/templates/shared/actions/runner_list.tmpl @@ -9,6 +9,9 @@ {{svg "octicon-triangle-down" 14 "dropdown icon"}}