mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-10-09 08:51:53 +02:00
Fork PRs (and Dependabot PRs) never receive repository secrets, so the Docker Hub login ran with empty credentials and Docker Scout then died with "user githubactions not entitled to use Docker Scout". Every external contributor got a red docker check they could not do anything about (#368, #356, #357, #359). - Gate the Docker Hub login and both Scout PR steps on the PR coming from this repo, the same guard the login/push/comment steps already use. - Add a Trivy scan of the locally loaded image on every PR. It needs no credentials, so forks get real vulnerability feedback. Output goes to the job summary because GITHUB_TOKEN is read-only on fork PRs. - Only upload the Scout SARIF when a file exists, so fork PRs stop logging "Path does not exist: scout-results.sarif". - Update the workflows README, which still described a docker-scan.yml that no longer exists. Not using pull_request_target on purpose: it would run untrusted PR code with the Docker Hub token available.
359 lines
14 KiB
YAML
359 lines
14 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
|
|
|
|
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=latest" >> $GITHUB_OUTPUT
|
|
echo "APP_VERSION=dev" >> $GITHUB_OUTPUT
|
|
echo "No version tag, using 'latest'"
|
|
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 }}
|
|
tags: |
|
|
type=edge,branch=$repo.default_branch
|
|
type=semver,pattern=v{{version}}
|
|
type=sha,prefix=,suffix=,format=short
|
|
type=raw,value=latest,enable={{is_default_branch}}
|
|
type=raw,value=${{ steps.tag_version.outputs.VERSION }}
|
|
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
|
|
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
|
|
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
|
|
image: ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
|
|
sarif-file: scout-results.sarif
|
|
summary: true
|
|
exit-code: false
|
|
only-severities: critical,high
|
|
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
|
|
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 version 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 version 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 to ${APP_VERSION}"
|
|
|
|
jq --arg version "${APP_VERSION}" '.version = $version' package.json > package.json.tmp
|
|
mv package.json.tmp package.json
|
|
|
|
if git diff --quiet -- package.json; then
|
|
echo "package.json 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
|
|
git commit -m "chore: sync version to ${APP_VERSION}"
|
|
git push origin "HEAD:${TARGET_BRANCH}"
|