ci: publish docker images to ghcr (#1860)
* ci: publish docker images to ghcr * ci: remove ghcr images with no tags * ci: remove unnecessary ghcr jq args for empty tags * ci: move ghcr empty tag clean up Publishes Docker container images on both DockerHub and GitHub Container Registry.pull/1867/head
parent
92f3de28bb
commit
e816a2e563
|
@ -37,6 +37,7 @@ fi
|
||||||
|
|
||||||
if [[ $BUILDKITE_LABEL =~ ":docker: Deploy" ]]; then
|
if [[ $BUILDKITE_LABEL =~ ":docker: Deploy" ]]; then
|
||||||
docker logout
|
docker logout
|
||||||
|
docker logout ghcr.io
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $BUILDKITE_LABEL == ":docker: Deploy Manifests" ]] && [[ $BUILDKITE_BRANCH == "master" ]] && [[ $BUILDKITE_PULL_REQUEST == "false" ]]; then
|
if [[ $BUILDKITE_LABEL == ":docker: Deploy Manifests" ]] && [[ $BUILDKITE_BRANCH == "master" ]] && [[ $BUILDKITE_PULL_REQUEST == "false" ]]; then
|
||||||
|
@ -49,6 +50,9 @@ if [[ $BUILDKITE_LABEL == ":docker: Deploy Manifests" ]] && [[ $BUILDKITE_BRANCH
|
||||||
comm -23 <(echo "${dockerbranchtags}") <(echo "${githubbranches}")); do
|
comm -23 <(echo "${dockerbranchtags}") <(echo "${githubbranches}")); do
|
||||||
echo "Removing tag ${BRANCH_TAG}"
|
echo "Removing tag ${BRANCH_TAG}"
|
||||||
curl -fsL --retry 3 -o /dev/null -X "DELETE" -H "Authorization: JWT ${authtoken}" https://hub.docker.com/v2/repositories/authelia/authelia/tags/${BRANCH_TAG}/
|
curl -fsL --retry 3 -o /dev/null -X "DELETE" -H "Authorization: JWT ${authtoken}" https://hub.docker.com/v2/repositories/authelia/authelia/tags/${BRANCH_TAG}/
|
||||||
|
for GHCR_VERSION in $(curl -fsL --retry 3 -H "Authorization: Bearer ${GHCR_PASSWORD}" -H "Accept: application/vnd.github.v3+json" https://api.github.com/orgs/authelia/packages/container/authelia/versions | jq --arg tag ${BRANCH_TAG} '.[] | select(.metadata.container.tags[] | contains($tag)) | .id'); do
|
||||||
|
curl -fsL --retry 3 -o /dev/null -X "DELETE" -H "Authorization: Bearer ${GHCR_PASSWORD}" -H "Accept: application/vnd.github.v3+json" https://api.github.com/orgs/authelia/packages/container/authelia/versions/${GHCR_VERSION}
|
||||||
|
done
|
||||||
done
|
done
|
||||||
echo "--- :docker: Removing tags for merged or closed pull requests"
|
echo "--- :docker: Removing tags for merged or closed pull requests"
|
||||||
for PR_TAG in $(dockerprtags=$(curl -fsL --retry 3 -H "Authorization: Bearer ${anontoken}" https://registry-1.docker.io/v2/authelia/authelia/tags/list | jq -r '.tags[] | select(startswith("PR"))' | \
|
for PR_TAG in $(dockerprtags=$(curl -fsL --retry 3 -H "Authorization: Bearer ${anontoken}" https://registry-1.docker.io/v2/authelia/authelia/tags/list | jq -r '.tags[] | select(startswith("PR"))' | \
|
||||||
|
@ -57,5 +61,12 @@ if [[ $BUILDKITE_LABEL == ":docker: Deploy Manifests" ]] && [[ $BUILDKITE_BRANCH
|
||||||
comm -23 <(echo "${dockerprtags}") <(echo "${githubprs}")); do
|
comm -23 <(echo "${dockerprtags}") <(echo "${githubprs}")); do
|
||||||
echo "Removing tag ${PR_TAG}"
|
echo "Removing tag ${PR_TAG}"
|
||||||
curl -fsL --retry 3 -o /dev/null -X "DELETE" -H "Authorization: JWT ${authtoken}" https://hub.docker.com/v2/repositories/authelia/authelia/tags/${PR_TAG}/
|
curl -fsL --retry 3 -o /dev/null -X "DELETE" -H "Authorization: JWT ${authtoken}" https://hub.docker.com/v2/repositories/authelia/authelia/tags/${PR_TAG}/
|
||||||
|
for GHCR_VERSION in $(curl -fsL --retry 3 -H "Authorization: Bearer ${GHCR_PASSWORD}" -H "Accept: application/vnd.github.v3+json" https://api.github.com/orgs/authelia/packages/container/authelia/versions | jq --arg tag ${PR_TAG} '.[] | select(.metadata.container.tags[] | contains($tag)) | .id'); do
|
||||||
|
curl -fsL --retry 3 -o /dev/null -X "DELETE" -H "Authorization: Bearer ${GHCR_PASSWORD}" -H "Accept: application/vnd.github.v3+json" https://api.github.com/orgs/authelia/packages/container/authelia/versions/${GHCR_VERSION}
|
||||||
|
done
|
||||||
|
done
|
||||||
|
echo "--- :docker: Removing empty tags for old images on GHCR"
|
||||||
|
for GHCR_VERSION in $(curl -fsL --retry 3 -H "Authorization: Bearer ${GHCR_PASSWORD}" -H "Accept: application/vnd.github.v3+json" https://api.github.com/orgs/authelia/packages/container/authelia/versions | jq '.[] | select((.metadata.container.tags|length) == 0) | .id'); do
|
||||||
|
curl -fsL --retry 3 -o /dev/null -X "DELETE" -H "Authorization: Bearer ${GHCR_PASSWORD}" -H "Accept: application/vnd.github.v3+json" https://api.github.com/orgs/authelia/packages/container/authelia/versions/${GHCR_VERSION}
|
||||||
done
|
done
|
||||||
fi
|
fi
|
|
@ -13,7 +13,7 @@ do
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "--- :github: Deploy artifacts for release: ${BUILDKITE_TAG}"
|
echo "--- :github: Deploy artifacts for release: ${BUILDKITE_TAG}"
|
||||||
hub release create "${BUILDKITE_TAG}" "${artifacts[@]}" -F <(echo -e "${BUILDKITE_TAG}\n$(conventional-changelog -p angular -o /dev/stdout -r 2 | sed -e '1,3d')\n\n### Docker Container\n* \`docker pull authelia/authelia:${BUILDKITE_TAG//v}\`"); EXIT=$?
|
hub release create "${BUILDKITE_TAG}" "${artifacts[@]}" -F <(echo -e "${BUILDKITE_TAG}\n$(conventional-changelog -p angular -o /dev/stdout -r 2 | sed -e '1,3d')\n\n### Docker Container\n* \`docker pull authelia/authelia:${BUILDKITE_TAG//v}\`\n* \`docker pull ghcr.io/authelia/authelia:${BUILDKITE_TAG//v}\`"); EXIT=$?
|
||||||
|
|
||||||
if [[ $EXIT == 0 ]];
|
if [[ $EXIT == 0 ]];
|
||||||
then
|
then
|
||||||
|
|
|
@ -13,10 +13,11 @@ on('pull_request.opened')
|
||||||
context.payload.pull_request.head.ref.slice(0, 9) !== 'renovate/'
|
context.payload.pull_request.head.ref.slice(0, 9) !== 'renovate/'
|
||||||
)
|
)
|
||||||
.comment(`## Artifacts
|
.comment(`## Artifacts
|
||||||
These changes are published for testing on Buildkite and DockerHub.
|
These changes are published for testing on Buildkite, DockerHub and GitHub Container Registry.
|
||||||
|
|
||||||
### Docker Container
|
### Docker Container
|
||||||
* \`docker pull authelia/authelia:{{ pull_request.head.ref }}\``)
|
* \`docker pull authelia/authelia:{{ pull_request.head.ref }}\`
|
||||||
|
* \`docker pull ghcr.io/authelia/authelia:{{ pull_request.head.ref }}\``)
|
||||||
|
|
||||||
// PR commentary for third party based contributions
|
// PR commentary for third party based contributions
|
||||||
on('pull_request.opened')
|
on('pull_request.opened')
|
||||||
|
@ -29,7 +30,8 @@ on('pull_request.opened')
|
||||||
You are free to apply the changes if you're comfortable, alternatively you are welcome to ask a team member for advice.
|
You are free to apply the changes if you're comfortable, alternatively you are welcome to ask a team member for advice.
|
||||||
|
|
||||||
## Artifacts
|
## Artifacts
|
||||||
These changes once approved by a team member will be published for testing on Buildkite and DockerHub.
|
These changes once approved by a team member will be published for testing on Buildkite, DockerHub and GitHub Container Registry.
|
||||||
|
|
||||||
### Docker Container
|
### Docker Container
|
||||||
* \`docker pull authelia/authelia:PR{{ pull_request.number }}\``)
|
* \`docker pull authelia/authelia:PR{{ pull_request.number }}\`
|
||||||
|
* \`docker pull ghcr.io/authelia/authelia:PR{{ pull_request.number }}\``)
|
|
@ -23,8 +23,6 @@ linters:
|
||||||
- goimports
|
- goimports
|
||||||
- golint
|
- golint
|
||||||
- gosec
|
- gosec
|
||||||
- interfacer
|
|
||||||
- maligned
|
|
||||||
- misspell
|
- misspell
|
||||||
- nolintlint
|
- nolintlint
|
||||||
- prealloc
|
- prealloc
|
||||||
|
|
|
@ -23,7 +23,7 @@ var ciPullRequest = os.Getenv("BUILDKITE_PULL_REQUEST")
|
||||||
var ciTag = os.Getenv("BUILDKITE_TAG")
|
var ciTag = os.Getenv("BUILDKITE_TAG")
|
||||||
var dockerTags = regexp.MustCompile(`v(?P<Patch>(?P<Minor>(?P<Major>\d+)\.\d+)\.\d+.*)`)
|
var dockerTags = regexp.MustCompile(`v(?P<Patch>(?P<Minor>(?P<Major>\d+)\.\d+)\.\d+.*)`)
|
||||||
var ignoredSuffixes = regexp.MustCompile("alpha|beta")
|
var ignoredSuffixes = regexp.MustCompile("alpha|beta")
|
||||||
var publicRepo = regexp.MustCompile(`.*\:.*`)
|
var publicRepo = regexp.MustCompile(`.*:.*`)
|
||||||
var tags = dockerTags.FindStringSubmatch(ciTag)
|
var tags = dockerTags.FindStringSubmatch(ciTag)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
@ -144,30 +144,39 @@ var DockerManifestCmd = &cobra.Command{
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func login(docker *Docker) {
|
func login(docker *Docker, registry string) {
|
||||||
username := os.Getenv("DOCKER_USERNAME")
|
username := ""
|
||||||
password := os.Getenv("DOCKER_PASSWORD")
|
password := ""
|
||||||
|
|
||||||
|
switch registry {
|
||||||
|
case dockerhub:
|
||||||
|
username = os.Getenv("DOCKER_USERNAME")
|
||||||
|
password = os.Getenv("DOCKER_PASSWORD")
|
||||||
|
case ghcr:
|
||||||
|
username = os.Getenv("GHCR_USERNAME")
|
||||||
|
password = os.Getenv("GHCR_PASSWORD")
|
||||||
|
}
|
||||||
|
|
||||||
if username == "" {
|
if username == "" {
|
||||||
log.Fatal(errors.New("DOCKER_USERNAME is empty"))
|
log.Fatal(errors.New("DOCKER_USERNAME/GHCR_USERNAME is empty"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if password == "" {
|
if password == "" {
|
||||||
log.Fatal(errors.New("DOCKER_PASSWORD is empty"))
|
log.Fatal(errors.New("DOCKER_PASSWORD/GHCR_PASSWORD is empty"))
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("Login to Docker Hub as %s", username)
|
log.Infof("Login to %s as %s", registry, username)
|
||||||
err := docker.Login(username, password)
|
err := docker.Login(username, password, registry)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Login to Docker Hub failed", err)
|
log.Fatalf("Login to %s failed: %s", registry, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func deploy(docker *Docker, tag string) {
|
func deploy(docker *Docker, tag, registry string) {
|
||||||
imageWithTag := DockerImageName + ":" + tag
|
imageWithTag := registry + "/" + DockerImageName + ":" + tag
|
||||||
|
|
||||||
log.Infof("Docker image %s will be deployed on Docker Hub", imageWithTag)
|
log.Infof("Docker image %s will be deployed on %s", imageWithTag, registry)
|
||||||
|
|
||||||
if err := docker.Tag(DockerImageName, imageWithTag); err != nil {
|
if err := docker.Tag(DockerImageName, imageWithTag); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
@ -178,10 +187,10 @@ func deploy(docker *Docker, tag string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func deployManifest(docker *Docker, tag string, amd64tag string, arm32v7tag string, arm64v8tag string) {
|
func deployManifest(docker *Docker, tag, amd64tag, arm32v7tag, arm64v8tag, registry string) {
|
||||||
dockerImagePrefix := DockerImageName + ":"
|
dockerImagePrefix := registry + "/" + DockerImageName + ":"
|
||||||
|
|
||||||
log.Infof("Docker manifest %s%s will be deployed on Docker Hub", dockerImagePrefix, tag)
|
log.Infof("Docker manifest %s%s will be deployed on %s", dockerImagePrefix, tag, registry)
|
||||||
|
|
||||||
err := docker.Manifest(dockerImagePrefix+tag, dockerImagePrefix+amd64tag, dockerImagePrefix+arm32v7tag, dockerImagePrefix+arm64v8tag)
|
err := docker.Manifest(dockerImagePrefix+tag, dockerImagePrefix+amd64tag, dockerImagePrefix+arm32v7tag, dockerImagePrefix+arm64v8tag)
|
||||||
|
|
||||||
|
@ -190,6 +199,8 @@ func deployManifest(docker *Docker, tag string, amd64tag string, arm32v7tag stri
|
||||||
}
|
}
|
||||||
|
|
||||||
tags := []string{amd64tag, arm32v7tag, arm64v8tag}
|
tags := []string{amd64tag, arm32v7tag, arm64v8tag}
|
||||||
|
|
||||||
|
if registry == dockerhub {
|
||||||
for _, t := range tags {
|
for _, t := range tags {
|
||||||
log.Infof("Docker removing tag for %s%s on Docker Hub", dockerImagePrefix, t)
|
log.Infof("Docker removing tag for %s%s on Docker Hub", dockerImagePrefix, t)
|
||||||
|
|
||||||
|
@ -197,74 +208,88 @@ func deployManifest(docker *Docker, tag string, amd64tag string, arm32v7tag stri
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func publishDockerImage(arch string) {
|
func publishDockerImage(arch string) {
|
||||||
docker := &Docker{}
|
docker := &Docker{}
|
||||||
|
|
||||||
|
for _, registry := range registries {
|
||||||
switch {
|
switch {
|
||||||
case ciTag != "":
|
case ciTag != "":
|
||||||
if len(tags) == 4 {
|
if len(tags) == 4 {
|
||||||
log.Infof("Detected tags: '%s' | '%s' | '%s'", tags[1], tags[2], tags[3])
|
log.Infof("Detected tags: '%s' | '%s' | '%s'", tags[1], tags[2], tags[3])
|
||||||
login(docker)
|
login(docker, registry)
|
||||||
deploy(docker, tags[1]+"-"+arch)
|
deploy(docker, tags[1]+"-"+arch, registry)
|
||||||
|
|
||||||
if !ignoredSuffixes.MatchString(ciTag) {
|
if !ignoredSuffixes.MatchString(ciTag) {
|
||||||
deploy(docker, tags[2]+"-"+arch)
|
deploy(docker, tags[2]+"-"+arch, registry)
|
||||||
deploy(docker, tags[3]+"-"+arch)
|
deploy(docker, tags[3]+"-"+arch, registry)
|
||||||
deploy(docker, "latest-"+arch)
|
deploy(docker, "latest-"+arch, registry)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.Fatal("Docker image will not be published, the specified tag does not conform to the standard")
|
log.Fatal("Docker image will not be published, the specified tag does not conform to the standard")
|
||||||
}
|
}
|
||||||
case ciBranch != masterTag && !publicRepo.MatchString(ciBranch):
|
case ciBranch != masterTag && !publicRepo.MatchString(ciBranch):
|
||||||
login(docker)
|
login(docker, registry)
|
||||||
deploy(docker, ciBranch+"-"+arch)
|
deploy(docker, ciBranch+"-"+arch, registry)
|
||||||
case ciBranch != masterTag && publicRepo.MatchString(ciBranch):
|
case ciBranch != masterTag && publicRepo.MatchString(ciBranch):
|
||||||
login(docker)
|
login(docker, registry)
|
||||||
deploy(docker, "PR"+ciPullRequest+"-"+arch)
|
deploy(docker, "PR"+ciPullRequest+"-"+arch, registry)
|
||||||
case ciBranch == masterTag && ciPullRequest == stringFalse:
|
case ciBranch == masterTag && ciPullRequest == stringFalse:
|
||||||
login(docker)
|
login(docker, registry)
|
||||||
deploy(docker, "master-"+arch)
|
deploy(docker, "master-"+arch, registry)
|
||||||
default:
|
default:
|
||||||
log.Info("Docker image will not be published")
|
log.Info("Docker image will not be published")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func publishDockerManifest() {
|
func publishDockerManifest() {
|
||||||
docker := &Docker{}
|
docker := &Docker{}
|
||||||
|
|
||||||
|
for _, registry := range registries {
|
||||||
switch {
|
switch {
|
||||||
case ciTag != "":
|
case ciTag != "":
|
||||||
if len(tags) == 4 {
|
if len(tags) == 4 {
|
||||||
log.Infof("Detected tags: '%s' | '%s' | '%s'", tags[1], tags[2], tags[3])
|
log.Infof("Detected tags: '%s' | '%s' | '%s'", tags[1], tags[2], tags[3])
|
||||||
login(docker)
|
login(docker, registry)
|
||||||
deployManifest(docker, tags[1], tags[1]+"-amd64", tags[1]+"-arm32v7", tags[1]+"-arm64v8")
|
deployManifest(docker, tags[1], tags[1]+"-amd64", tags[1]+"-arm32v7", tags[1]+"-arm64v8", registry)
|
||||||
|
|
||||||
|
if registry == dockerhub {
|
||||||
publishDockerReadme(docker)
|
publishDockerReadme(docker)
|
||||||
|
}
|
||||||
|
|
||||||
if !ignoredSuffixes.MatchString(ciTag) {
|
if !ignoredSuffixes.MatchString(ciTag) {
|
||||||
deployManifest(docker, tags[2], tags[2]+"-amd64", tags[2]+"-arm32v7", tags[2]+"-arm64v8")
|
deployManifest(docker, tags[2], tags[2]+"-amd64", tags[2]+"-arm32v7", tags[2]+"-arm64v8", registry)
|
||||||
deployManifest(docker, tags[3], tags[3]+"-amd64", tags[3]+"-arm32v7", tags[3]+"-arm64v8")
|
deployManifest(docker, tags[3], tags[3]+"-amd64", tags[3]+"-arm32v7", tags[3]+"-arm64v8", registry)
|
||||||
deployManifest(docker, "latest", "latest-amd64", "latest-arm32v7", "latest-arm64v8")
|
deployManifest(docker, "latest", "latest-amd64", "latest-arm32v7", "latest-arm64v8", registry)
|
||||||
|
|
||||||
|
if registry == dockerhub {
|
||||||
publishDockerReadme(docker)
|
publishDockerReadme(docker)
|
||||||
updateMicroBadger(docker)
|
updateMicroBadger(docker)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
log.Fatal("Docker manifest will not be published, the specified tag does not conform to the standard")
|
log.Fatal("Docker manifest will not be published, the specified tag does not conform to the standard")
|
||||||
}
|
}
|
||||||
case ciBranch != masterTag && !publicRepo.MatchString(ciBranch):
|
case ciBranch != masterTag && !publicRepo.MatchString(ciBranch):
|
||||||
login(docker)
|
login(docker, registry)
|
||||||
deployManifest(docker, ciBranch, ciBranch+"-amd64", ciBranch+"-arm32v7", ciBranch+"-arm64v8")
|
deployManifest(docker, ciBranch, ciBranch+"-amd64", ciBranch+"-arm32v7", ciBranch+"-arm64v8", registry)
|
||||||
case ciBranch != masterTag && publicRepo.MatchString(ciBranch):
|
case ciBranch != masterTag && publicRepo.MatchString(ciBranch):
|
||||||
login(docker)
|
login(docker, registry)
|
||||||
deployManifest(docker, "PR"+ciPullRequest, "PR"+ciPullRequest+"-amd64", "PR"+ciPullRequest+"-arm32v7", "PR"+ciPullRequest+"-arm64v8")
|
deployManifest(docker, "PR"+ciPullRequest, "PR"+ciPullRequest+"-amd64", "PR"+ciPullRequest+"-arm32v7", "PR"+ciPullRequest+"-arm64v8", registry)
|
||||||
case ciBranch == masterTag && ciPullRequest == stringFalse:
|
case ciBranch == masterTag && ciPullRequest == stringFalse:
|
||||||
login(docker)
|
login(docker, registry)
|
||||||
deployManifest(docker, "master", "master-amd64", "master-arm32v7", "master-arm64v8")
|
deployManifest(docker, "master", "master-amd64", "master-arm32v7", "master-arm64v8", registry)
|
||||||
|
|
||||||
|
if registry == dockerhub {
|
||||||
publishDockerReadme(docker)
|
publishDockerReadme(docker)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
log.Info("Docker manifest will not be published")
|
log.Info("Docker manifest will not be published")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func publishDockerReadme(docker *Docker) {
|
func publishDockerReadme(docker *Docker) {
|
||||||
|
|
|
@ -9,6 +9,11 @@ var DockerImageName = "authelia/authelia"
|
||||||
// IntermediateDockerImageName local name of the docker image.
|
// IntermediateDockerImageName local name of the docker image.
|
||||||
var IntermediateDockerImageName = "authelia:dist"
|
var IntermediateDockerImageName = "authelia:dist"
|
||||||
|
|
||||||
|
var registries = []string{"docker.io", "ghcr.io"}
|
||||||
|
|
||||||
|
const dockerhub = "docker.io"
|
||||||
|
const ghcr = "ghcr.io"
|
||||||
|
|
||||||
const masterTag = "master"
|
const masterTag = "master"
|
||||||
const stringFalse = "false"
|
const stringFalse = "false"
|
||||||
const stringTrue = "true"
|
const stringTrue = "true"
|
||||||
|
|
|
@ -20,8 +20,8 @@ func (d *Docker) Tag(image, tag string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login login to the dockerhub registry.
|
// Login login to the dockerhub registry.
|
||||||
func (d *Docker) Login(username, password string) error {
|
func (d *Docker) Login(username, password, registry string) error {
|
||||||
return utils.CommandWithStdout("docker", "login", "-u", username, "-p", password).Run()
|
return utils.CommandWithStdout("docker", "login", registry, "-u", username, "-p", password).Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push push a docker image to dockerhub.
|
// Push push a docker image to dockerhub.
|
||||||
|
|
Loading…
Reference in New Issue