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
55 changes: 48 additions & 7 deletions .github/workflows/docker-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ on:
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."
description: "Fully qualified digest ref of an already-published image to promote (e.g. sisqueslabs/beacon-api@sha256:...) — see trunk-ci-cd.yml's image_digest output. Optional for bump_mode=promote: leave empty to auto-resolve the current :edge tag (the latest build validated on main)."
required: false
type: string
default: ""
Expand Down Expand Up @@ -130,10 +130,6 @@ 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 @@ -189,7 +185,7 @@ jobs:
# Legacy mode: manual workflow_dispatch releases bump relative to the
# version currently in package.json.
- name: Bump version (legacy)
if: inputs.bump_mode != 'release-train'
if: inputs.bump_mode == 'legacy'
id: bump_legacy
run: |
if [ "${{ inputs.release_type }}" = "stable" ]; then
Expand All @@ -200,12 +196,40 @@ jobs:
echo "version=${NEW_VERSION#v}" >> "$GITHUB_OUTPUT"
echo "tag=${NEW_VERSION}" >> "$GITHUB_OUTPUT"

# Promote mode: there is only one channel (stable) and no manual
# "version" input to ask for — the bump is computed the same way
# release-train-detect does it for its `main` channel: scan
# conventional commits since the latest stable tag (feat: -> minor,
# a breaking-change marker -> major, anything else -> patch). Trunk-
# based main only ever moves forward via reviewed PRs, so this range
# is exactly "what shipped since the last release."
- name: Set version (promote)
if: inputs.bump_mode == 'promote'
id: bump_promote
run: |
LATEST_STABLE=$(git tag -l 'v[0-9]*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || true)
if [ -n "$LATEST_STABLE" ]; then RANGE="${LATEST_STABLE}..HEAD"; else RANGE="HEAD"; fi
LOG=$(git log "$RANGE" --pretty='%s%n%b' 2>/dev/null || true)
if echo "$LOG" | grep -qiE '^[a-z]+(\([^)]*\))?!:' || echo "$LOG" | grep -qE '^BREAKING[ -]CHANGE:'; then
BUMP="major"
elif echo "$LOG" | grep -qiE '^feat(\([^)]*\))?:'; then
BUMP="minor"
else
BUMP="patch"
fi
NEW_VERSION=$(npm version "$BUMP" --no-git-tag-version)
echo "version=${NEW_VERSION#v}" >> "$GITHUB_OUTPUT"
echo "tag=${NEW_VERSION}" >> "$GITHUB_OUTPUT"

- name: Export version outputs
id: bump
run: |
if [ "${{ inputs.bump_mode }}" = "release-train" ]; then
echo "version=${{ steps.bump_train.outputs.version }}" >> "$GITHUB_OUTPUT"
echo "tag=${{ steps.bump_train.outputs.tag }}" >> "$GITHUB_OUTPUT"
elif [ "${{ inputs.bump_mode }}" = "promote" ]; then
echo "version=${{ steps.bump_promote.outputs.version }}" >> "$GITHUB_OUTPUT"
echo "tag=${{ steps.bump_promote.outputs.tag }}" >> "$GITHUB_OUTPUT"
else
echo "version=${{ steps.bump_legacy.outputs.version }}" >> "$GITHUB_OUTPUT"
echo "tag=${{ steps.bump_legacy.outputs.tag }}" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -359,6 +383,23 @@ jobs:
username: ${{ github.actor }}
password: ${{ github.token }}

# Only runs when the caller didn't pass an explicit source_digest —
# the common case is "release whatever's currently on :edge" (the
# latest build trunk-ci-cd.yml validated on main), so this is what
# makes bump_mode=promote a true one-click release with no required
# inputs. Passing source_digest explicitly still overrides this, e.g.
# to release an earlier commit while main has since moved on.
- name: "Resolve source digest (auto: latest :edge build)"
if: inputs.bump_mode == 'promote' && inputs.source_digest == ''
id: resolve_digest
run: |
DIGEST=$(docker buildx imagetools inspect "${{ inputs.image_name }}:edge" | awk '/^Digest:/{print $2; exit}')
if [ -z "$DIGEST" ]; then
echo "::error::Could not resolve the :edge digest for ${{ inputs.image_name }}. Has trunk-ci-cd.yml published a build on main yet?"
exit 1
fi
echo "digest=${{ inputs.image_name }}@${DIGEST}" >> "$GITHUB_OUTPUT"

# 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.
Expand Down Expand Up @@ -433,7 +474,7 @@ jobs:
if: inputs.bump_mode == 'promote'
env:
TAGS: ${{ steps.tags.outputs.list }}
SOURCE_DIGEST: ${{ inputs.source_digest }}
SOURCE_DIGEST: ${{ inputs.source_digest != '' && inputs.source_digest || steps.resolve_digest.outputs.digest }}
run: |
ARGS=()
while IFS= read -r tag; do
Expand Down
25 changes: 17 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -435,32 +435,41 @@ gains a `promote` bump mode alongside `legacy`/`release-train`. It skips the
build entirely and retags an already-published digest — the one that went
through `dev` and `pre` above — onto the release tags with
`docker buildx imagetools create`, so the exact bytes validated in `pre` are
what ships to `prod`. Everything else (version bump, git tag, changelog,
GitHub Release) works exactly like `legacy` mode.
what ships to `prod`. Changelog and GitHub Release generation work exactly
like `legacy` mode.

**Zero required inputs — this is a one-click release.** Unlike `legacy`
mode, `promote` never asks for a version bump type: there is only one
channel (`stable`) once a repo is trunk-based, so the bump (patch/minor/
major) is computed automatically from conventional commits since the
latest stable tag — the same logic `release-train-detect` already uses for
its `main` channel. `source_digest` is optional too: leave it empty and the
workflow resolves the current `:edge` tag (the latest build `trunk-ci-cd.yml`
validated on `main`) automatically. Pass `source_digest` explicitly only for
the exceptional case of releasing an earlier commit while `main` has since
moved on.

```yaml
name: Release

on:
workflow_dispatch:
inputs:
version:
type: choice
options: [patch, minor, major]
source_digest:
description: "Digest to promote (from a trunk-ci-cd.yml run's image_digest output)"
required: true
description: "Optional: digest to promote (from a trunk-ci-cd.yml run's image_digest output). Leave empty to auto-promote the latest :edge build."
required: false
type: string

jobs:
release:
uses: sisques-labs/workflows/.github/workflows/docker-release.yml@main
with:
image_name: sisqueslabs/my-app
version: ${{ inputs.version }}
release_type: stable
bump_mode: promote
source_digest: ${{ inputs.source_digest }}
run_lint: false # no rebuild happens under promote — the digest was
run_test: false # already lint/tested when trunk-ci-cd.yml built it
secrets:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
Expand Down
11 changes: 11 additions & 0 deletions openspec/changes/trunk-based-ci-cd/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ No file this change touches is edited in place for its existing behavior: `trunk

`docker-release.yml`'s existing git-cliff + GitHub Release steps apply unchanged when `release_type: stable`. Because there is only one channel once a repo migrates, the alpha/beta/rc tag-ignoring logic in the changelog range computation becomes dead code for that repo's future releases but is not removed here (other repos still exercise it via `legacy`/`release-train` modes).

### D8 — `promote` mode has zero required inputs (discovered during the beacon-api pilot)

The first pilot implementation still asked a human for a `version` bump type (patch/minor/major) and a required `source_digest`, mirroring `legacy` mode's manual dropdown. That was a design bug, not an intentional choice: `bump_mode: promote`'s "Bump version" step accidentally ran the same code path as `legacy` (its `if:` condition was `!= 'release-train'`, which is also true for `promote`).

Fixed: `promote` now computes both automatically —

- **Version bump**: scanned from conventional commits since the latest stable tag (identical algorithm to `release-train-detect`'s `main`-channel logic: a breaking-change marker → major, `feat:` → minor, anything else → patch). There is only one channel once a repo is trunk-based, so there is nothing for a human to choose between.
- **Source digest**: when not passed explicitly, resolved from the current `:edge` tag (the latest build `trunk-ci-cd.yml` validated on `main`) via `docker buildx imagetools inspect`. An explicit `source_digest` still overrides this, for releasing an earlier commit while `main` has since moved on.

This makes cutting a release a genuine one-click action (`workflow_dispatch` with no required fields), matching the spirit of the whole migration: humans decide *when* to release, the pipeline decides *what* the release contains.

## Risks / Trade-offs

- **[Risk] Multi-arch digest promotion is unproven** → Validate `imagetools create` against a real multi-platform image before beacon-api's pilot relies on it for an actual prod release.
Expand Down
11 changes: 9 additions & 2 deletions openspec/changes/trunk-based-ci-cd/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@

## 4. Verification

- [ ] 4.1 Dry-run `trunk-ci-cd.yml` against a disposable test repo/branch to confirm the `build-and-publish` → `deploy-dev` → `deploy-pre` ordering holds
- [ ] 4.1 Dry-run `trunk-ci-cd.yml` against a disposable test repo/branch to confirm the `build-and-publish` → `deploy-dev` → `deploy-pre` ordering holds. **In progress** — beacon-api PR #23 is the live test.
- [ ] 4.2 Dry-run `docker-release.yml` with `bump_mode: promote` against a real multi-arch image and confirm the promoted tag pulls correctly on both architectures
- [ ] 4.3 Confirm no existing repo's `release-train.yml`-based pipeline changed behavior after this PR merges
- [x] 4.3 Confirm no existing repo's `release-train.yml`-based pipeline changed behavior after this PR merges — `release-train-detect.test.sh` still 36/36 after every change to `docker-release.yml`; `release-train.yml` itself untouched

## 5. `promote` mode zero-input fix (D8)

- [x] 5.1 Separate `bump_mode: promote`'s version step from `legacy`'s — `promote` no longer runs the manual `npm version ${{ inputs.version }}` path
- [x] 5.2 Compute the version bump for `promote` automatically from conventional commits since the latest stable tag (mirrors `release-train-detect`'s `main`-channel logic)
- [x] 5.3 Make `source_digest` optional for `promote`: auto-resolve the current `:edge` tag's digest via `docker buildx imagetools inspect` when not passed explicitly
- [ ] 5.4 Dry-run a real `promote` release with zero inputs and confirm the resolved version + digest are correct — needs a real repo with an `:edge` build published (part of 4.1/4.2's live test)