mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-10-11 01:41:52 +02:00
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.
455 lines
18 KiB
YAML
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"
|