Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 10 additions & 58 deletions .github/workflows/ci-docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ on:
required: false
type: string
default: "false"
# Path-based change detection for selective Docker builds
# These are pre-computed by the caller (cicd.yml) to include CI config fallback
webapp_changed:
description: "Whether webapp files changed (or should build for other reasons)"
required: false
Expand Down Expand Up @@ -64,21 +62,11 @@ jobs:
docker-file: "./webapp/Dockerfile"
docker-context: "."
registry: "ghcr.io"
# Bake commit + branch (the Dockerfile's ARGs) so the footer build strip is
# populated on CI-built deploys; Coolify sets these itself for previews.
# head_ref, not ref_name: on a pull request the latter is the merge ref `<n>/merge`, which is
# not a branch and does not resolve as one — the footer links it and got a 404.
# Use the PR source branch; ref_name identifies GitHub's synthetic merge ref on pull requests.
build-args: |
SOURCE_COMMIT=${{ github.sha }}
COOLIFY_BRANCH=${{ github.head_ref || github.ref_name }}
# amd64-only on PRs (main/release build both arches). See reusable-docker-build.yml.
single-arch: ${{ github.event_name == 'pull_request' }}
tags: |
${{ github.ref_name }}
${{ github.sha }}
${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }}
ci-${{ github.run_number }}
${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || '' }}
single-arch: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
labels: |
org.opencontainers.image.title=Hephaestus Webapp
org.opencontainers.image.description=Web client for Hephaestus
Expand All @@ -99,33 +87,18 @@ jobs:
attestations: write
with:
image-name: "hephaestus-build/application-server"
# Paketo Cloud Native Buildpacks via spring-boot:build-image, with Application CDS.
# See docs/admin/buildpacks-cds-decision.md and pom.xml's <image> block.
# Application CDS configuration lives in server/application/pom.xml; rationale: docs/admin/buildpacks-cds-decision.md.
use-buildpacks: true
maven-module: "server/application"
registry: "ghcr.io"
# amd64-only on PRs (main/release build both arches). See reusable-docker-build.yml.
single-arch: ${{ github.event_name == 'pull_request' }}
# github.sha is a synthetic merge commit on pull_request. Coolify injects the checked-out PR
# head as SOURCE_COMMIT, so publish both immutable identifiers.
tags: |
${{ github.ref_name }}
${{ github.sha }}
${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }}
ci-${{ github.run_number }}
${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || '' }}
single-arch: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
labels: |
org.opencontainers.image.title=Hephaestus Application Server
org.opencontainers.image.description=Spring Boot server for Hephaestus
org.opencontainers.image.vendor=AET TUM
org.opencontainers.image.licenses=MIT
hephaestus.component=application-server

# Note: the inbound webhook receiver lives in the application-server image
# (integration.core.webhook package) and is deployed as a separate container via
# SPRING_PROFILES_ACTIVE=prod,webhook. No separate image build is required. See
# docker/compose.core.yaml (webhook-server service) and ADR 0008.

agent-pi-build:
name: "Agent: Pi"
if: inputs.should_skip != 'true' && inputs.agent_images_changed == 'true'
Expand All @@ -140,17 +113,7 @@ jobs:
docker-file: "./docker/agents/pi/Dockerfile"
docker-context: "./docker/agents"
registry: "ghcr.io"
# amd64-only on PRs (main/release build both arches). See reusable-docker-build.yml.
single-arch: ${{ github.event_name == 'pull_request' }}
# The head-SHA tag matters here for the same reason it does on application-server: a preview
# runs at SOURCE_COMMIT and derives its agent image from that tag, so without it no preview can
# ever resolve a matched pair, however the pull request touched the agent tree (ADR 0031).
tags: |
${{ github.ref_name }}
${{ github.sha }}
${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }}
ci-${{ github.run_number }}
${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || '' }}
single-arch: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
labels: |
org.opencontainers.image.title=Hephaestus Agent - Pi
org.opencontainers.image.description=Sandboxed Pi coding agent for AI-powered code review
Expand All @@ -174,23 +137,15 @@ jobs:
docker-file: "./docker/postgres/Dockerfile"
docker-context: "./docker/postgres"
registry: "ghcr.io"
# amd64-only on PRs (main/release build both arches). See reusable-docker-build.yml.
single-arch: ${{ github.event_name == 'pull_request' }}
tags: |
${{ github.ref_name }}
${{ github.sha }}
${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }}
ci-${{ github.run_number }}
${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || '' }}
single-arch: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
labels: |
org.opencontainers.image.title=Hephaestus Postgres
org.opencontainers.image.description=PostgreSQL + pg_partman for all environments (dev/preview/prod)
org.opencontainers.image.vendor=AET TUM
org.opencontainers.image.licenses=MIT
hephaestus.component=postgres

# Preview stacks use one commit tag for every component. Point unchanged components at their
# verified base-commit digest instead of rebuilding identical content under a new name.
# Preview stacks resolve every component by PR head SHA; alias verified base digests for unchanged components.
tag-unchanged-images:
name: "Tag unchanged images"
if: >-
Expand Down Expand Up @@ -229,16 +184,15 @@ jobs:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
REPOSITORY: ${{ github.repository }}
REPOSITORY_OWNER: ${{ github.repository_owner }}
CURRENT_SHA: ${{ github.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
RUN_NUMBER: ${{ github.run_number }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
run: |
set -euo pipefail

tag_image() {
local image="ghcr.io/hephaestus-build/$1"
local source="$image:$BASE_SHA"
local ref_tag="${GITHUB_REF_NAME//\//-}"
local digest
digest=$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "$source" 2>/dev/null || true)
if [[ ! "$digest" =~ ^sha256:[a-f0-9]{64}$ ]]; then
Expand All @@ -262,11 +216,9 @@ jobs:
> /dev/null
gh attestation verify "oci://$verified" --owner "$REPOSITORY_OWNER"
docker buildx imagetools create \
--tag "$image:$ref_tag" \
--tag "$image:$CURRENT_SHA" \
--tag "$image:$HEAD_SHA" \
--tag "$image:ci-$RUN_NUMBER" \
--tag "$image:pr-$PR_NUMBER" \
--tag "$image:run-$RUN_ID-$RUN_ATTEMPT" \
"$verified"
}

Expand Down
14 changes: 5 additions & 9 deletions .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -319,16 +319,12 @@ jobs:
Docker:
uses: ./.github/workflows/ci-docker-build.yml
secrets:
SENTRY_AUTH_TOKEN: ${{ github.event_name != 'pull_request' && secrets.SENTRY_AUTH_TOKEN || '' }}
SENTRY_ORG: ${{ github.event_name != 'pull_request' && secrets.SENTRY_ORG || '' }}
SENTRY_PROJECT: ${{ github.event_name != 'pull_request' && secrets.SENTRY_PROJECT || '' }}
# Image builds consume the detected source tree, not Quality outputs. Running both branches at
# once keeps image and preview confidence without adding Docker as a second CI stage.
SENTRY_AUTH_TOKEN: ${{ github.event_name == 'push' && secrets.SENTRY_AUTH_TOKEN || '' }}
SENTRY_ORG: ${{ github.event_name == 'push' && secrets.SENTRY_ORG || '' }}
SENTRY_PROJECT: ${{ github.event_name == 'push' && secrets.SENTRY_PROJECT || '' }}
needs: [detect-changes]
# A preview runs the images CI published for its exact head commit, so a same-repository pull
# request needs commit-addressed tags even when it touches only docs — `Tag unchanged images`
# re-tags rather than rebuilds. The path filters therefore gate fork pull requests only; forks
# never receive a preview.
# Same-repository previews require every image at the PR head tag; unchanged images are aliased.
# Forks cannot preview, so their image builds remain path-filtered.
if: |
needs.detect-changes.outputs.should_skip != 'true' && (
github.event_name != 'pull_request' ||
Expand Down
25 changes: 4 additions & 21 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,5 @@
name: Release

# Cuts a release when a merge to main bumped the root package version — i.e.
# when the Version PR (maintained by version-pr.yml) has been merged. Triggered
# after CI/CD succeeds so the Docker images for the commit already exist.
#
# A release: tags vX.Y.Z at the version-bump commit, creates the GitHub Release
# from that version's CHANGELOG.md section (flagging schema migrations), verifies
# a seeded previous-release upgrade before publication, retags the CI-built
# images (X.Y.Z, X.Y, latest), and starts the deploy chain
# (staging automatically, production after environment approval).
#
# Versioning contract: docs/admin/compatibility-policy.mdx

on:
Expand Down Expand Up @@ -56,8 +46,6 @@ jobs:

- name: Cut release if this commit bumped the version
id: cut
# GITHUB_TOKEN suffices: the tag/Release it creates is not meant to
# trigger any workflow (deploys are wired via `needs`/dispatch below).
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SHA: ${{ github.event.workflow_run.head_sha }}
Expand Down Expand Up @@ -94,9 +82,6 @@ jobs:
jq -e '.isDraft == false and .isPrerelease == false' <<< "$previous" >/dev/null || {
echo "::error::$PREV_TAG is not a stable published release"; exit 1; }

# Release notes = this version's CHANGELOG.md section (the assembled
# changeset entries), with a first-class migration warning when the
# release touches the Liquibase changelog.
NOTES=$(mktemp)
if [ -n "$PREV_TAG" ] && ! git diff --quiet "$PREV_TAG" "$SHA" -- server/application/src/main/resources/db/changelog/; then
{
Expand Down Expand Up @@ -171,7 +156,7 @@ jobs:
- name: Capture release image digests
id: retag
env:
SHA: ${{ needs.release.outputs.sha }}
SOURCE_TAG: run-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}
run: |
set -euo pipefail

Expand All @@ -180,17 +165,17 @@ jobs:
declare -A DIGESTS=()
: > release-images.tsv

# Resolve every source digest before publishing any release tag.
# Resolve the complete image inventory before generating evidence.
for img in "${IMAGES[@]}"; do
FULL_IMAGE="ghcr.io/hephaestus-build/$img"
for i in $(seq 1 24); do
digest=$(docker buildx imagetools inspect "$FULL_IMAGE:$SHA" --format '{{json .Manifest}}' 2>/dev/null | jq -er '.digest' 2>/dev/null) || true
digest=$(docker buildx imagetools inspect "$FULL_IMAGE:$SOURCE_TAG" --format '{{json .Manifest}}' 2>/dev/null | jq -er '.digest' 2>/dev/null) || true
if [[ "$digest" =~ ^sha256:[a-f0-9]{64}$ ]]; then
DIGESTS[$img]=$digest
break
fi
if [ "$i" -ge 24 ]; then
echo "::error::Could not resolve a valid digest for $FULL_IMAGE:$SHA after 120 s"
echo "::error::Could not resolve a valid digest for $FULL_IMAGE:$SOURCE_TAG after 120 s"
exit 1
fi
sleep 5
Expand Down Expand Up @@ -637,8 +622,6 @@ jobs:
packages: read
with:
image-tag: ${{ needs.release.outputs.tag_name }}
# Deploy credentials include organization secrets, which reach nested reusable
# workflows only through inheritance — environment secrets alone do not cover them.
secrets: inherit # zizmor: ignore[secrets-inherit]

deploy-production:
Expand Down
55 changes: 18 additions & 37 deletions .github/workflows/reusable-docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,6 @@ on:
type: string
default: "ghcr.io"
description: "Container registry"
tags:
type: string
required: false
description: "Additional tags (one per line)"
labels:
type: string
required: false
Expand All @@ -56,16 +52,21 @@ on:
type: boolean
required: false
default: false
description: |
Build only linux/amd64. Callers set this on pull requests to halve Docker build
wall-clock + cost (the arm64 native build adds ~7 min and PR images are never deployed
to production). main + release builds leave this false so release tags and pinned
digests stay multi-arch.
description: "Build only linux/amd64 instead of the supported multi-architecture set."
outputs:
manifest-digest:
description: "Published image digest (a multi-architecture manifest-list digest for main and releases)."
description: "Published image digest; an index digest for multi-architecture builds."
value: ${{ inputs.single-arch && jobs.build.outputs.manifest-digest || jobs.merge.outputs.manifest-digest }}

env:
STANDARD_IMAGE_TAGS: |
${{ github.event_name == 'push' && github.ref_name || '' }}
${{ github.event_name == 'push' && github.sha || '' }}
${{ github.event_name == 'push' && format('ci-{0}', github.run_number) || '' }}
${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }}
${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || '' }}
run-${{ github.run_id }}-${{ github.run_attempt }}

jobs:
build:
name: Build ${{ matrix.platform }} Docker Image
Expand All @@ -88,15 +89,10 @@ jobs:
fetch-depth: 1
persist-credentials: false

# Note: QEMU is NOT needed because we build natively:
# - linux/amd64 builds on ubuntu-24.04 (x86_64)
# - linux/arm64 builds on ubuntu-24.04-arm (aarch64)

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
with:
# Use TUM's Docker Hub mirror to avoid rate limits (standard ls1intum approach)
# See: https://github.com/ls1intum/.github/blob/main/.github/workflows/build-and-push-docker-image.yml
# Avoid anonymous Docker Hub pull limits.
buildkitd-config-inline: |
[registry."docker.io"]
mirrors = ["https://docker-mirror.ase.in.tum.de:8765"]
Expand All @@ -114,9 +110,6 @@ jobs:
run: |
{
echo "tags<<EOF"
echo "type=ref,event=branch"
echo "type=ref,event=pr"
echo "type=sha,prefix="
while IFS= read -r line || [[ -n "$line" ]]; do
line=$(echo "$line" | xargs)
[ -z "$line" ] && continue
Expand All @@ -129,11 +122,9 @@ jobs:
[ -n "$value" ] && echo "type=raw,value=$value"
done
fi
done <<< "$INPUT_TAGS"
done <<< "$STANDARD_IMAGE_TAGS"
echo "EOF"
} >> "$GITHUB_OUTPUT"
env:
INPUT_TAGS: ${{ inputs.tags }}

- name: Extract metadata
id: meta
Expand All @@ -154,7 +145,6 @@ jobs:
INPUT_IMAGE_NAME: ${{ inputs.image-name }}
MATRIX_PLATFORM: ${{ matrix.platform }}

# Only main may write the shared registry cache.
- name: Build and push (Dockerfile)
id: build
if: ${{ !inputs.use-buildpacks }}
Expand All @@ -167,9 +157,9 @@ jobs:
platforms: ${{ matrix.platform }}
build-args: ${{ inputs.build-args }}
secrets: |
sentry_auth_token=${{ github.event_name != 'pull_request' && matrix.platform == 'linux/amd64' && secrets.SENTRY_AUTH_TOKEN || '' }}
sentry_org=${{ github.event_name != 'pull_request' && matrix.platform == 'linux/amd64' && secrets.SENTRY_ORG || '' }}
sentry_project=${{ github.event_name != 'pull_request' && matrix.platform == 'linux/amd64' && secrets.SENTRY_PROJECT || '' }}
sentry_auth_token=${{ github.event_name == 'push' && matrix.platform == 'linux/amd64' && secrets.SENTRY_AUTH_TOKEN || '' }}
sentry_org=${{ github.event_name == 'push' && matrix.platform == 'linux/amd64' && secrets.SENTRY_ORG || '' }}
sentry_project=${{ github.event_name == 'push' && matrix.platform == 'linux/amd64' && secrets.SENTRY_PROJECT || '' }}
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ inputs.single-arch && steps.meta.outputs.tags || '' }}
push: ${{ inputs.single-arch }}
Expand Down Expand Up @@ -364,14 +354,8 @@ jobs:
run: |
{
echo "tags<<EOF"
# Default tags. Note: `latest` is NOT applied here — it tracks the latest
# release and is applied by the release workflow's retag step, together
# with the X.Y.Z and X.Y tags.
echo "type=ref,event=branch"
echo "type=ref,event=pr"
echo "type=sha,prefix="
if [ -n "${INPUTS_TAGS}" ]; then
echo "${INPUTS_TAGS}" | while IFS= read -r line || [[ -n "$line" ]]; do
if [ -n "${STANDARD_IMAGE_TAGS}" ]; then
echo "${STANDARD_IMAGE_TAGS}" | while IFS= read -r line || [[ -n "$line" ]]; do
line=$(echo "$line" | xargs)
if [ -n "$line" ]; then
if [[ "$line" == *"type="* ]]; then
Expand All @@ -390,8 +374,6 @@ jobs:
fi
echo "EOF"
} >> $GITHUB_OUTPUT
env:
INPUTS_TAGS: ${{ inputs.tags }}

- name: Extract metadata
id: meta
Expand Down Expand Up @@ -449,7 +431,6 @@ jobs:
run: |
cosign sign --yes --recursive "$IMAGE_REF"

# Pin cert SAN to this workflow + cross-check the workflow-repo OID.
- name: Verify signature + attestation
env:
IMAGE_REF: ${{ inputs.registry }}/${{ inputs.image-name }}@${{ steps.digest.outputs.manifest-digest }}
Expand Down
Loading
Loading