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
52 changes: 44 additions & 8 deletions .github/workflows/docker-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ on:
type: string
default: ""
bump_mode:
description: "Version bump mode (legacy | release-train)"
description: "Version bump mode (legacy | release-train | promote)"
required: false
type: string
default: legacy
Expand All @@ -67,6 +67,11 @@ on:
required: false
type: string
default: ""
source_digest:
description: "Fully qualified digest ref of an already-published image to promote (e.g. sisqueslabs/beacon-api@sha256:...). Required when bump_mode=promote — see trunk-ci-cd.yml's image_digest output."
required: false
type: string
default: ""
sync_develop_after_stable:
description: "After a stable release-train graduate, merge main into develop"
required: false
Expand Down Expand Up @@ -125,6 +130,10 @@ jobs:
echo "::error::bump_mode=release-train requires a non-empty next_version input."
exit 1
fi
if [ "${{ inputs.bump_mode }}" = "promote" ] && [ -z "${{ inputs.source_digest }}" ]; then
echo "::error::bump_mode=promote requires a non-empty source_digest input."
exit 1
fi

- name: Checkout
uses: actions/checkout@v7.0.1
Expand Down Expand Up @@ -310,9 +319,12 @@ jobs:
# built with push:false aren't docker-loadable, so this builds a
# linux/amd64-only copy with load:true purely to scan locally — it's
# never pushed. This reuses the buildx cache the real multi-arch build
# below will also read from, so the extra build is cheap.
# below will also read from, so the extra build is cheap. Skipped for
# bump_mode=promote: that mode never builds from source, so there is
# nothing local to scan (the promoted digest was already scanned when
# it was originally built, e.g. by trunk-ci-cd.yml).
- name: Build image for vulnerability scan (linux/amd64)
if: inputs.scan_image
if: inputs.scan_image && inputs.bump_mode != 'promote'
uses: docker/build-push-action@v7
with:
context: ${{ inputs.context }}
Expand All @@ -327,7 +339,7 @@ jobs:
cache-to: type=gha,mode=max

- name: Scan image for vulnerabilities
if: inputs.scan_image
if: inputs.scan_image && inputs.bump_mode != 'promote'
uses: sisques-labs/workflows/.github/actions/trivy-scan@main
with:
image_ref: ${{ inputs.image_name }}:scan
Expand All @@ -349,7 +361,9 @@ jobs:

# Build first WITHOUT pushing: if the image cannot be built, nothing is
# published anywhere (no git tag, no registry tag, no GitHub Release).
# Skipped for bump_mode=promote — see "Promote existing image" below.
- name: Build image (no push)
if: inputs.bump_mode != 'promote'
uses: docker/build-push-action@v7.3.0
with:
context: ${{ inputs.context }}
Expand Down Expand Up @@ -395,7 +409,9 @@ jobs:

# The registry push reuses the buildx cache from the build step, so this
# is a near-instant publish of the exact image that was just built.
# Skipped for bump_mode=promote — see "Promote existing image" below.
- name: Push image
if: inputs.bump_mode != 'promote'
uses: docker/build-push-action@v7.3.0
with:
context: ${{ inputs.context }}
Expand All @@ -407,10 +423,30 @@ jobs:
node_auth_token=${{ secrets.NODE_AUTH_TOKEN }}
cache-from: type=gha

# By the time this step runs, "Push image" has already succeeded (a
# failed push stops the job before reaching here), so image_name was
# published and the Docker Hub login above is known-good — no separate
# "did we actually publish" check is needed.
# bump_mode=promote never builds from source: this runs only after the
# git tag above is already pushed, so the same "tag can never lag
# behind a published image" guarantee holds. It retags the exact
# digest that already passed through pre (see trunk-ci-cd.yml) onto the
# release tags computed earlier — the bytes that ship to prod are
# byte-for-byte what was validated in pre, never a fresh rebuild.
- name: Promote existing image (imagetools)
if: inputs.bump_mode == 'promote'
env:
TAGS: ${{ steps.tags.outputs.list }}
SOURCE_DIGEST: ${{ inputs.source_digest }}
run: |
ARGS=()
while IFS= read -r tag; do
[ -z "$tag" ] && continue
ARGS+=(--tag "$tag")
done <<< "$TAGS"
docker buildx imagetools create "${ARGS[@]}" "$SOURCE_DIGEST"

# By the time this step runs, either "Push image" or "Promote existing
# image" has already succeeded (a failure in either stops the job
# before reaching here), so image_name was published and the Docker
# Hub login above is known-good — no separate "did we actually
# publish" check is needed.
#
# NOTE: peter-evans/dockerhub-description requires a Docker Hub
# password/PAT with Read/Write/Delete scope. A token scoped only for
Expand Down
222 changes: 222 additions & 0 deletions .github/workflows/trunk-ci-cd.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
name: Trunk CI/CD

# See openspec/changes/trunk-based-ci-cd/design.md for the full design.
#
# Every merge to a trunk-based consumer's main branch builds ONE image,
# published only under commit-addressed tags (sha-<shortsha>, edge) — no
# semver, no git tag, no GitHub Release here. That same image is then
# deployed to dev, then pre (needs: deploy-dev, so pre never sees a build dev
# hasn't seen first). Promoting the validated digest to a versioned stable
# release for prod happens separately, via docker-release.yml's
# bump_mode=promote — this workflow never cuts a release itself.

on:
workflow_call:
inputs:
image_name:
description: "Docker Hub image name (e.g. sisqueslabs/beacon-api)"
required: true
type: string
ghcr_image_name:
description: "GHCR image name (e.g. ghcr.io/sisques-labs/beacon-api). Required when push_ghcr=true."
required: false
type: string
default: ""
push_ghcr:
description: "Also publish the image to GitHub Container Registry (GHCR)"
required: false
type: boolean
default: false
dockerfile:
description: "Path to Dockerfile"
required: false
type: string
default: Dockerfile
context:
description: "Build context path"
required: false
type: string
default: "."
node_version:
description: "Node.js version to use"
required: false
type: string
default: "22"
run_lint:
description: "Whether to run lint before building"
required: false
type: boolean
default: true
run_test:
description: "Whether to run tests before building"
required: false
type: boolean
default: true
platforms:
description: "Target platforms (e.g. linux/amd64,linux/arm64)"
required: false
type: string
default: "linux/amd64,linux/arm64"
secrets:
DOCKERHUB_USERNAME:
required: true
DOCKERHUB_TOKEN:
required: true
NODE_AUTH_TOKEN:
description: "GitHub token with read:packages scope for @sisques-labs private packages"
required: false
outputs:
image_digest:
description: "Fully qualified digest ref of the published image (image_name@sha256:...) — pass this as docker-release.yml's source_digest input when cutting a stable release from this build."
value: ${{ jobs.build-and-publish.outputs.image_digest }}
short_sha:
description: "Short commit SHA used for the sha-<shortsha> tag."
value: ${{ jobs.build-and-publish.outputs.short_sha }}

permissions:
contents: read
packages: write

jobs:
build-and-publish:
name: Build & Publish (continuous)
runs-on: ubuntu-latest
outputs:
image_digest: ${{ steps.digest.outputs.ref }}
short_sha: ${{ steps.sha.outputs.short }}
steps:
- name: Validate inputs
run: |
if [ "${{ inputs.push_ghcr }}" = "true" ] && [ -z "${{ inputs.ghcr_image_name }}" ]; then
echo "::error::push_ghcr=true requires a non-empty ghcr_image_name input."
exit 1
fi

- name: Checkout
uses: actions/checkout@v7.0.1

- name: Short SHA
id: sha
run: echo "short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"

- name: Setup pnpm
uses: pnpm/action-setup@v6

- name: Setup Node.js
uses: actions/setup-node@v7.0.0
with:
node-version: ${{ inputs.node_version }}
cache: pnpm

- name: Configure npm for private packages
env:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
run: |
if [ -n "${NODE_AUTH_TOKEN}" ]; then
echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.npmrc
echo "@sisques-labs:registry=https://npm.pkg.github.com" >> ~/.npmrc
fi

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Lint
if: inputs.run_lint
run: pnpm lint

- name: Test
if: inputs.run_test
run: pnpm test

# No semver here on purpose: continuous builds are commit-addressed
# only. Versioning happens later, at release time, by promoting this
# exact digest (see docker-release.yml bump_mode=promote) — never by
# rebuilding from source.
- name: Compute tag list
id: tags
env:
IMAGE_NAME: ${{ inputs.image_name }}
GHCR_IMAGE_NAME: ${{ inputs.ghcr_image_name }}
PUSH_GHCR: ${{ inputs.push_ghcr }}
SHORT_SHA: ${{ steps.sha.outputs.short }}
run: |
{
echo "list<<TAGS_EOF"
echo "${IMAGE_NAME}:sha-${SHORT_SHA}"
echo "${IMAGE_NAME}:edge"
if [ "${PUSH_GHCR}" = "true" ]; then
echo "${GHCR_IMAGE_NAME}:sha-${SHORT_SHA}"
echo "${GHCR_IMAGE_NAME}:edge"
fi
echo "TAGS_EOF"
} >> "$GITHUB_OUTPUT"

- name: Set up QEMU
uses: docker/setup-qemu-action@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4

- name: Log in to Docker Hub
uses: docker/login-action@v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Log in to GHCR
if: inputs.push_ghcr
uses: docker/login-action@v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}

- name: Build & push image
id: push
uses: docker/build-push-action@v7.3.0
with:
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
platforms: ${{ inputs.platforms }}
push: true
tags: ${{ steps.tags.outputs.list }}
secrets: |
node_auth_token=${{ secrets.NODE_AUTH_TOKEN }}
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Compose digest ref
id: digest
env:
IMAGE_NAME: ${{ inputs.image_name }}
DIGEST: ${{ steps.push.outputs.digest }}
run: echo "ref=${IMAGE_NAME}@${DIGEST}" >> "$GITHUB_OUTPUT"

# Placeholder: no consuming repo has real dev infrastructure provisioned yet
# (see openspec/changes/trunk-based-ci-cd/design.md, D4). This job fixes the
# pipeline's shape and ordering now; replace this step with a real deploy
# once a repo has a dev target.
deploy-dev:
name: Deploy (dev)
needs: build-and-publish
runs-on: ubuntu-latest
environment: dev
steps:
- name: Deploy placeholder
run: |
echo "No dev deploy target configured yet."
echo "Would deploy: ${{ needs.build-and-publish.outputs.image_digest }}"

# Sequenced after deploy-dev on purpose (needs:) so pre never receives a
# build that dev hasn't seen first. Gate pre for real via the pre GitHub
# Environment's required reviewers, not by editing this workflow.
deploy-pre:
name: Deploy (pre)
needs: [build-and-publish, deploy-dev]
runs-on: ubuntu-latest
environment: pre
steps:
- name: Deploy placeholder
run: |
echo "No pre deploy target configured yet."
echo "Would deploy: ${{ needs.build-and-publish.outputs.image_digest }}"
Loading