Files
gitea-mirror/.github/workflows/docker-build.yml
T
ARUNAVO RAYandGitHub 3df3fa577e fix(ci): stop main builds from overwriting the latest image tag (#431)
A merge and the release bump that follows it land on main seconds apart.
Both triggered the Docker workflow and both pushed latest, and on v3.36.1
the older build finished last, so latest carried 3.36.0 until the weekly
rebuild replaced it (#425).

The workflow now runs one build per ref at a time, cancelling superseded
branch builds but never a tag build. The latest tag is only pushed by a
stable release tag build, main builds push edge and the short sha, and
Docker Scout scans the digest the run just pushed instead of whatever
latest pointed at.
2026-09-16 07:31:45 +05:30

455 lines
18 KiB
YAML

name: Docker Build, Push & Security Scan
on:
push:
branches: [main]
tags: ['v*']
paths:
- 'Dockerfile'
- '.dockerignore'
- 'package.json'
- 'bun.lock*'
- '.github/workflows/docker-build.yml'
- 'docker-entrypoint.sh'
- 'drizzle/**'
- 'scripts/**'
- 'src/**'
pull_request:
paths:
- 'Dockerfile'
- '.dockerignore'
- 'package.json'
- 'bun.lock*'
- '.github/workflows/docker-build.yml'
- 'docker-entrypoint.sh'
- 'drizzle/**'
- 'scripts/**'
- 'src/**'
schedule:
- cron: '0 0 * * 0' # Weekly security scan on Sunday at midnight
# One build per ref at a time. A merge and the release bump that follows it
# land on main seconds apart and used to race each other for the latest tag
# (#425). Branch and PR builds cancel the older run; tag builds always finish.
concurrency:
group: docker-build-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
env:
REGISTRY: ghcr.io
IMAGE: ${{ github.repository }}
SHA: ${{ github.event.pull_request.head.sha || github.event.after }}
jobs:
docker:
runs-on: ubuntu-latest
timeout-minutes: 25
permissions:
contents: write
packages: write
security-events: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ env.SHA }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
with:
driver-opts: network=host
- name: Log into registry
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Login to Docker Hub for Docker Scout (optional - provides better vulnerability data)
# Add DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets to enable this.
# Skipped on fork PRs: GitHub never exposes repository secrets to them,
# so the login could only fail with "Username and password required".
- name: Log into Docker Hub
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
continue-on-error: true
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Extract version from tag if present
- name: Extract version from tag
id: tag_version
run: |
if [[ $GITHUB_REF == refs/tags/v* ]]; then
TAG_VERSION="${GITHUB_REF#refs/tags/}"
if [[ ! "$TAG_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Release tag '${TAG_VERSION}' is invalid. Expected semver tag format like v1.2.3 or v1.2.3-rc.1"
exit 1
fi
APP_VERSION="${TAG_VERSION#v}"
echo "VERSION=${TAG_VERSION}" >> $GITHUB_OUTPUT
echo "APP_VERSION=${APP_VERSION}" >> $GITHUB_OUTPUT
echo "Using version tag: ${TAG_VERSION}"
else
echo "VERSION=edge" >> $GITHUB_OUTPUT
echo "APP_VERSION=dev" >> $GITHUB_OUTPUT
echo "No version tag, using 'edge'"
fi
# Keep version files aligned automatically for tag-based releases
- name: Sync app version from release tag
if: startsWith(github.ref, 'refs/tags/v')
run: |
VERSION="${{ steps.tag_version.outputs.APP_VERSION }}"
echo "Syncing package.json version to ${VERSION}"
jq --arg version "${VERSION}" '.version = $version' package.json > package.json.tmp
mv package.json.tmp package.json
echo "Version sync diff (package.json):"
git --no-pager diff -- package.json
# Extract metadata for Docker
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE }}
labels: |
org.opencontainers.image.revision=${{ env.SHA }}
# latest follows the release tag, not main: a main build can carry
# an unreleased package.json and must never overwrite a release.
# Pre-release tags (v1.2.3-rc.1) keep latest on the last stable one.
tags: |
type=edge,branch=main
type=semver,pattern=v{{version}}
type=sha,prefix=,suffix=,format=short
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }}
type=raw,value=${{ steps.tag_version.outputs.VERSION }},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=ref,event=pr,prefix=pr-
# Build and push Docker image
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
# The runner stage installs Debian security updates; never serve it from cache.
no-cache-filters: runner
cache-to: type=gha,mode=max
provenance: false # Disable provenance to avoid unknown/unknown
sbom: false # Disable sbom to avoid unknown/unknown
# Load image locally for security scanning (PRs only)
- name: Load image for scanning
if: github.event_name == 'pull_request'
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
platforms: linux/amd64
load: true
tags: gitea-mirror:scan
cache-from: type=gha
# The runner stage installs Debian security updates; never serve it from cache.
no-cache-filters: runner
provenance: false # Disable provenance to avoid unknown/unknown
sbom: false # Disable sbom to avoid unknown/unknown
# Wait for image to be available in registry
- name: Wait for image availability
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
run: |
echo "Waiting for image to be available in registry..."
sleep 5
# Add comment to PR with image details
- name: Comment PR with image tag
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const prNumber = context.payload.pull_request.number;
const imageTag = `pr-${prNumber}`;
const imagePath = `${{ env.REGISTRY }}/${{ env.IMAGE }}:${imageTag}`.toLowerCase();
const comment = `## 🐳 Docker Image Built Successfully
Your PR image is available for testing:
**Image Tag:** \`${imageTag}\`
**Full Image Path:** \`${imagePath}\`
### Pull and Test
\`\`\`bash
docker pull ${imagePath}
docker run -d \
-p 4321:4321 \
-e BETTER_AUTH_SECRET=your-secret-here \
-e BETTER_AUTH_URL=http://localhost:4321 \
--name gitea-mirror-test ${imagePath}
\`\`\`
### Docker Compose Testing
\`\`\`yaml
services:
gitea-mirror:
image: ${imagePath}
ports:
- "4321:4321"
environment:
- BETTER_AUTH_SECRET=your-secret-here
- BETTER_AUTH_URL=http://localhost:4321
- BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:4321
\`\`\`
> 💡 **Note:** PR images are tagged as \`pr-<number>\` and built for both \`linux/amd64\` and \`linux/arm64\`.
> Production images (\`latest\`, version tags) use the same multi-platform set.
---
📦 View in [GitHub Packages](https://github.com/${{ github.repository }}/pkgs/container/gitea-mirror)`;
github.rest.issues.createComment({
issue_number: prNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body: comment
});
# Docker Scout comprehensive security analysis
- name: Docker Scout - Vulnerability Analysis & Recommendations
uses: docker/scout-action@bacf462e8d090c09660de30a6ccc718035f961e3 # v1.20.4
if: github.event_name != 'pull_request'
with:
command: cves,recommendations
# Scan the image this run pushed, whatever tags it carries.
image: ${{ env.REGISTRY }}/${{ env.IMAGE }}@${{ steps.build-and-push.outputs.digest }}
sarif-file: scout-results.sarif
summary: true
exit-code: false
only-severities: critical,high
# OpenVEX statements in .vex/ mark CVEs the image cannot reach as
# not affected (see .vex/README.md). Scout only accepts statements
# from authors listed here; the default is *@docker.com.
vex-location: .vex
vex-author: developer@arunavoray.dev
only-vex-affected: true
write-comment: true
github-token: ${{ secrets.GITHUB_TOKEN }}
# Docker Scout for Pull Requests (using local image).
# Same-repo PRs only: Scout needs the Docker Hub login above, and fork
# PRs never receive the secrets for it. They used to fail here with
# "user githubactions not entitled to use Docker Scout". Fork PRs get
# the credential-free Trivy scan further down instead.
- name: Docker Scout - Vulnerability Analysis (PR)
uses: docker/scout-action@bacf462e8d090c09660de30a6ccc718035f961e3 # v1.20.4
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
with:
command: cves,recommendations
image: local://gitea-mirror:scan
sarif-file: scout-results.sarif
summary: true
exit-code: false
only-severities: critical,high
vex-location: .vex
vex-author: developer@arunavoray.dev
only-vex-affected: true
write-comment: true
github-token: ${{ secrets.GITHUB_TOKEN }}
# Compare to latest (same-repo PRs only, same reason as above)
- name: Docker Scout - Compare to Latest
uses: docker/scout-action@bacf462e8d090c09660de30a6ccc718035f961e3 # v1.20.4
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
with:
command: compare
image: local://gitea-mirror:scan
to: ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
ignore-unchanged: true
only-severities: critical,high
write-comment: true
github-token: ${{ secrets.GITHUB_TOKEN }}
# Trivy runs on every PR, forks included. It needs no credentials, so it
# is the scan external contributors actually get. Results go to the job
# summary because GITHUB_TOKEN is read-only on fork PRs (no PR comments,
# no SARIF upload). Informational only, matching Scout's exit-code: false.
- name: Trivy - Vulnerability Analysis (PR)
if: github.event_name == 'pull_request'
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: gitea-mirror:scan
scanners: vuln
format: table
output: trivy-results.txt
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: '0'
- name: Trivy - Write job summary
if: github.event_name == 'pull_request'
run: |
{
echo "## Trivy image scan (critical and high, fixable only)"
echo
echo "Image: \`gitea-mirror:scan\` built from this PR. Unfixed vulnerabilities are hidden."
echo
if [ -s trivy-results.txt ]; then
echo '```'
cat trivy-results.txt
echo '```'
else
echo "No critical or high vulnerabilities with an available fix."
fi
} >> "$GITHUB_STEP_SUMMARY"
# GitHub rejects SARIF files where a single result carries more than
# 1000 relatedLocations, which Scout produces for widely-referenced OS
# packages — every upload had been silently failing on this since May
# ("rejecting SARIF, as there are more related locations per result than
# allowed"). Cap them so uploads flow and stale alerts can auto-close.
- name: Sanitize Scout SARIF (cap relatedLocations)
if: always()
continue-on-error: true
run: |
if [ -f scout-results.sarif ]; then
jq '(.runs[]?.results[]?) |= (if .relatedLocations then .relatedLocations |= .[:100] else . end)' \
scout-results.sarif > scout-results.sanitized.sarif
mv scout-results.sanitized.sarif scout-results.sarif
fi
# Upload security scan results to GitHub Security tab.
# Skipped when no Scout step ran (fork PRs), otherwise the upload
# errors with "Path does not exist: scout-results.sarif".
- name: Upload Docker Scout scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v4
if: always() && hashFiles('scout-results.sarif') != ''
continue-on-error: true
with:
sarif_file: scout-results.sarif
sync-version-main:
name: Sync package.json and Chart.yaml versions back to main
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
needs: docker
permissions:
contents: write
steps:
- name: Checkout default branch
uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
- name: Update package.json and Chart.yaml versions on main
env:
TAG_VERSION: ${{ github.ref_name }}
TARGET_BRANCH: ${{ github.event.repository.default_branch }}
run: |
if [[ ! "$TAG_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Release tag '${TAG_VERSION}' is invalid. Expected semver tag format like v1.2.3 or v1.2.3-rc.1"
exit 1
fi
APP_VERSION="${TAG_VERSION#v}"
echo "Syncing ${TARGET_BRANCH} package.json and Chart.yaml to ${APP_VERSION}"
jq --arg version "${APP_VERSION}" '.version = $version' package.json > package.json.tmp
mv package.json.tmp package.json
# The chart version tracks the app version so the default image tag
# always points at the release the chart was published with.
sed -i -E \
-e "s/^version: .*/version: ${APP_VERSION}/" \
-e "s/^appVersion: .*/appVersion: \"${APP_VERSION}\"/" \
helm/gitea-mirror/Chart.yaml
if git diff --quiet -- package.json helm/gitea-mirror/Chart.yaml; then
echo "Versions on ${TARGET_BRANCH} already at ${APP_VERSION}; nothing to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add package.json helm/gitea-mirror/Chart.yaml
git commit -m "chore: sync version to ${APP_VERSION}"
git push origin "HEAD:${TARGET_BRANCH}"
publish-helm-chart:
name: Publish Helm chart to ghcr.io
# Runs after the image for this tag is on the registry, so the chart never
# points at an image that does not exist yet. The chart version and
# appVersion both follow the release version.
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
timeout-minutes: 15
needs: docker
permissions:
contents: read
packages: write
steps:
- name: Checkout tag
uses: actions/checkout@v4
- name: Resolve version
id: version
env:
TAG_VERSION: ${{ github.ref_name }}
run: |
if [[ ! "$TAG_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Release tag '${TAG_VERSION}' is invalid. Expected semver tag format like v1.2.3 or v1.2.3-rc.1"
exit 1
fi
echo "version=${TAG_VERSION#v}" >> "$GITHUB_OUTPUT"
- name: Setup Helm
uses: azure/setup-helm@v4
with:
version: v3.19.0
- name: Lint and package chart
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
helm lint ./helm/gitea-mirror
helm package ./helm/gitea-mirror \
--version "$VERSION" \
--app-version "$VERSION" \
--destination dist
- name: Log into registry
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ACTOR: ${{ github.actor }}
run: |
echo "$GITHUB_TOKEN" | helm registry login "$REGISTRY" --username "$ACTOR" --password-stdin
- name: Push chart
env:
VERSION: ${{ steps.version.outputs.version }}
OWNER: ${{ github.repository_owner }}
run: |
OWNER_LC="$(echo "$OWNER" | tr '[:upper:]' '[:lower:]')"
helm push "dist/gitea-mirror-${VERSION}.tgz" "oci://${REGISTRY}/${OWNER_LC}/charts"
{
echo "### Helm chart ${VERSION} published"
echo
echo '```bash'
echo "helm upgrade --install gitea-mirror oci://${REGISTRY}/${OWNER_LC}/charts/gitea-mirror --version ${VERSION}"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"