diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 0fcdad7..0ff0626 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -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 @@ -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 @@ -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 @@ -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 }} @@ -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 @@ -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 }} @@ -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 }} @@ -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 diff --git a/.github/workflows/trunk-ci-cd.yml b/.github/workflows/trunk-ci-cd.yml new file mode 100644 index 0000000..9f0293c --- /dev/null +++ b/.github/workflows/trunk-ci-cd.yml @@ -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-, 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- 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<> "$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 }}" diff --git a/README.md b/README.md index 904137e..c1511c3 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ This repository contains reusable GitHub Actions workflows and composite actions │ ├── node-ci.yml # Lint, test, and build a Node.js app as parallel jobs │ ├── node-release.yml # Node.js release workflow with semantic-release │ ├── release-train.yml # Automatic alpha/beta/stable release train -│ ├── docker-release.yml # Version bump + Docker build & publish +│ ├── trunk-ci-cd.yml # Continuous build + dev/pre deploy for trunk-based repos +│ ├── docker-release.yml # Version bump + Docker build & publish (or promote, for trunk-based repos) │ ├── docker-smoke-build.yml # PR-time Dockerfile build + blocking vuln scan │ ├── codeql.yml # CodeQL security analysis (init + analyze) │ ├── pr-labeler.yml # Auto-label PRs by changed files @@ -376,6 +377,101 @@ jobs: `tests/release-train-detect.test.sh`, which runs on every PR to this repository (including a regression test for the stale-beta bug). +### Trunk CI/CD + +For repos on trunk-based development (a single long-lived `main`, no +`develop`/`staging`), `trunk-ci-cd.yml` replaces `release-train.yml`. See +`openspec/changes/trunk-based-ci-cd/design.md` in this repo for the full +rationale. **This workflow is purely additive** — it doesn't touch +`release-train.yml` or its branch→channel mapping, so no existing consumer +is affected by its existence. A repo opts in only by pointing its own +`push: [main]` workflow at `trunk-ci-cd.yml`. + +Unlike Release Train, every merge to `main` publishes an image but **never** +a version, a git tag, or a GitHub Release: + +| Trigger | Publishes | Then | +| ----------------- | ----------------------------------- | ------------------------- | +| push to `main` | `:sha-`, `:edge` | deploy to `dev`, then `pre` (`needs:`) | +| manual release cut | promotes the validated digest to `:X.Y.Z`, `:latest` | (see `docker-release.yml` `bump_mode: promote` below) | + +**Usage (consumer repository):** + +```yaml +name: Trunk CI/CD + +on: + push: + branches: [main] + +permissions: + contents: read + packages: write + +jobs: + pipeline: + uses: sisques-labs/workflows/.github/workflows/trunk-ci-cd.yml@main + with: + image_name: sisqueslabs/my-app + ghcr_image_name: ghcr.io/sisques-labs/my-app + push_ghcr: true + node_version: "22" + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} +``` + +**`deploy-dev`/`deploy-pre` are placeholders.** No consuming repo has real +`dev`/`pre` infrastructure provisioned yet, so both jobs currently only log +what they would deploy. The job graph and its `needs:` ordering (`pre` only +ever runs after `dev` succeeds) are the actual deliverable — replace the +placeholder step with a real deploy once a repo has an environment to target. +Configure the `dev`/`pre` GitHub Environments (and any required-reviewer +gates) in the consuming repo's own Settings — this shared workflow only +references the environment names by convention. + +**Cutting a `prod` release (`bump_mode: promote`):** `docker-release.yml` +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. + +```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 + 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 }} + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} +``` + +**⚠️ Unvalidated risk:** `imagetools create` must copy the full multi-arch +manifest list (`linux/amd64,linux/arm64`), not a single-platform digest. +This has not yet been validated against a real multi-arch image — confirm +it works before relying on `bump_mode: promote` for an actual production +release. + ### Branch sync after a stable release After a stable release (a push to `main` that graduates a release), both diff --git a/openspec/changes/trunk-based-ci-cd/tasks.md b/openspec/changes/trunk-based-ci-cd/tasks.md index 4971d60..9835d35 100644 --- a/openspec/changes/trunk-based-ci-cd/tasks.md +++ b/openspec/changes/trunk-based-ci-cd/tasks.md @@ -1,21 +1,21 @@ ## 1. `trunk-ci-cd.yml` reusable workflow -- [ ] 1.1 Add `.github/workflows/trunk-ci-cd.yml` (`workflow_call`) with a `build-and-publish` job: lint, test, build, push `image:sha-` + floating `image:edge` — no version bump, no git tag -- [ ] 1.2 Add `deploy-dev` job (`needs: build-and-publish`) with a placeholder deploy step (no real target yet) — verify it runs unconditionally after a successful build -- [ ] 1.3 Add `deploy-pre` job (`needs: deploy-dev`) with a placeholder deploy step and an optional consumer-provided gate input — verify it never runs before `deploy-dev` succeeds +- [x] 1.1 Add `.github/workflows/trunk-ci-cd.yml` (`workflow_call`) with a `build-and-publish` job: lint, test, build, push `image:sha-` + floating `image:edge` — no version bump, no git tag +- [x] 1.2 Add `deploy-dev` job (`needs: build-and-publish`) with a placeholder deploy step (no real target yet) — verify it runs unconditionally after a successful build +- [x] 1.3 Add `deploy-pre` job (`needs: deploy-dev`) with a placeholder deploy step — gating is configured via the `pre` GitHub Environment's required reviewers (D5), not a workflow input — verify it never runs before `deploy-dev` succeeds ## 2. `docker-release.yml` promote mode -- [ ] 2.1 Add `bump_mode: promote` as a new accepted value alongside `legacy`/`release-train` — verify existing modes' code paths are untouched -- [ ] 2.2 When `bump_mode: promote`, accept the source image digest as an input and skip the existing build steps entirely -- [ ] 2.3 Implement the promotion step with `docker buildx imagetools create` retagging the digest to the computed release tag(s) + `:latest` -- [ ] 2.4 Verify multi-arch manifest lists (`linux/amd64,linux/arm64`) survive the promotion intact, not just a single-platform digest — this is the flagged risk in `design.md` D2 -- [ ] 2.5 Confirm changelog (git-cliff) and GitHub Release creation steps run unchanged for `promote` mode when `release_type: stable` +- [x] 2.1 Add `bump_mode: promote` as a new accepted value alongside `legacy`/`release-train` — verify existing modes' code paths are untouched +- [x] 2.2 When `bump_mode: promote`, accept the source image digest as an input and skip the existing build steps entirely +- [x] 2.3 Implement the promotion step with `docker buildx imagetools create` retagging the digest to the computed release tag(s) + `:latest` +- [ ] 2.4 Verify multi-arch manifest lists (`linux/amd64,linux/arm64`) survive the promotion intact, not just a single-platform digest — this is the flagged risk in `design.md` D2. **Not yet run** — needs a real dry-run (see 4.2). +- [ ] 2.5 Confirm changelog (git-cliff) and GitHub Release creation steps run unchanged for `promote` mode when `release_type: stable`. Statically verified (no `bump_mode` condition gates those steps) but **not yet run live** — needs a real dry-run. ## 3. Documentation -- [ ] 3.1 Document the new `trunk-ci-cd.yml` inputs/outputs and the `promote` bump mode in this repo's README, alongside the existing `release-train.yml`/`docker-release.yml` docs -- [ ] 3.2 Note in the README that `trunk-ci-cd.yml` is opt-in per repo and does not affect existing `release-train.yml` consumers +- [x] 3.1 Document the new `trunk-ci-cd.yml` inputs/outputs and the `promote` bump mode in this repo's README, alongside the existing `release-train.yml`/`docker-release.yml` docs +- [x] 3.2 Note in the README that `trunk-ci-cd.yml` is opt-in per repo and does not affect existing `release-train.yml` consumers ## 4. Verification