diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 5e02fa60..e137be67 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -31,7 +31,8 @@ jobs: # --------------------------------------------------------------------------- publish-dashboard: name: Publish legacy ccs-dashboard image - # Skip on prerelease events — rc builds do not publish the legacy image + # Skip on prerelease release events — only stable vX.Y.Z GitHub releases + # publish the legacy image. workflow_dispatch always passes through. if: ${{ github.event_name != 'release' || !github.event.release.prerelease }} runs-on: [self-hosted, linux, x64, cliproxy] @@ -143,14 +144,15 @@ jobs: # --------------------------------------------------------------------------- # Job 2: Integrated image — CCS + CLIProxy (single build, no :full variant) - # Publishes ONLY the immutable : tag here. + # Publishes ONLY the immutable : tag here on every release event. # Mutable :latest / major / minor aliases are added by promote-mutable-tags - # AFTER smoke tests pass, preventing a bad image from reaching :latest. + # ONLY via explicit workflow_dispatch (promote_to_latest=true) after the + # operator has verified the immutable image is stable. This keeps the rc.1 + # soak window entirely in the Docker promotion path — npm @latest is always + # published immediately by semantic-release on the stable release. # --------------------------------------------------------------------------- publish-integrated: name: Publish integrated image - # Prerelease events (rc.N) publish the immutable version tag only. - # Mutable tags are never set on rc builds — promotion is always explicit. runs-on: [self-hosted, linux, x64, cliproxy] permissions: @@ -284,16 +286,15 @@ jobs: # --------------------------------------------------------------------------- # Job 3: Smoke test — pull the immutable : tag and verify it boots - # Runs after publish-integrated; mutable tags are only added if this passes. + # Runs after publish-integrated; mutable tags are only promoted if this passes. # --------------------------------------------------------------------------- smoke-test: name: Smoke test integrated image # Depends only on publish-integrated, NOT publish-dashboard. - # publish-dashboard is skipped on prerelease (rc.N) events — if it were - # listed here, GitHub Actions would also skip smoke-test and - # promote-mutable-tags on every rc publish, breaking the rc soak flow. + # publish-dashboard is skipped on GitHub prerelease events — if it were + # listed here, GitHub Actions would also skip smoke-test when that condition + # is not met, preventing the immutable tag from being verified. needs: [publish-integrated] - # Run on both rc and stable releases; skip if integrated publish was skipped if: ${{ needs.publish-integrated.outputs.publish == 'true' }} runs-on: [self-hosted, linux, x64, cliproxy] @@ -399,17 +400,19 @@ jobs: # --------------------------------------------------------------------------- # Job 4: Promote mutable tags — runs ONLY after smoke tests pass # Adds :latest, :, : aliases pointing to the immutable digest. - # Never runs for prerelease (rc) events. + # Never triggered automatically by release events — requires an explicit + # workflow_dispatch with promote_to_latest=true (via promote-release.yml or + # direct gh CLI call) after the operator confirms the immutable : image + # is stable. This is the sole rc.1 soak gate for Docker mutable tags. # --------------------------------------------------------------------------- promote-mutable-tags: name: Promote mutable tags (:latest / major / minor) needs: [smoke-test, publish-integrated] - # Only promote on stable release events or explicit promote_to_latest dispatch + # Only promote on explicit operator dispatch — never on automatic release events. + # Release events publish the immutable : tag only (see publish-integrated). if: | - needs.publish-integrated.outputs.publish == 'true' && ( - (github.event_name == 'release' && !github.event.release.prerelease) || - (github.event_name == 'workflow_dispatch' && inputs.promote_to_latest == true) - ) + needs.publish-integrated.outputs.publish == 'true' && + github.event_name == 'workflow_dispatch' && inputs.promote_to_latest == true runs-on: [self-hosted, linux, x64, cliproxy] permissions: diff --git a/.github/workflows/promote-release.yml b/.github/workflows/promote-release.yml index bc2798a0..7e0769c8 100644 --- a/.github/workflows/promote-release.yml +++ b/.github/workflows/promote-release.yml @@ -1,69 +1,98 @@ -name: Promote rc to Stable Release +name: Promote Stable Release to Docker Latest -# Manual workflow to promote a pre-release rc tag to a stable GitHub release. +# Manual workflow to promote a stable vX.Y.Z release to Docker mutable tags +# (:latest, :MAJOR, :MINOR) after the rc.1 soak window. # # Flow: -# 1. Flip the GitHub release from prerelease=true to prerelease=false using -# `gh release edit --prerelease=false`. -# 2. The resulting `release: published` event (with prerelease=false) triggers -# docker-release.yml, which builds/signs the image, runs smoke tests, then -# adds the mutable :latest/:MAJOR/:MINOR Docker tags via promote-mutable-tags. +# 1. Validate that the input tag exists as a stable (non-prerelease) GitHub release. +# 2. Dispatch docker-release.yml with promote_to_latest=true for the given tag. +# This triggers the promote-mutable-tags job (gated on workflow_dispatch +# with promote_to_latest=true) which adds :latest/:MAJOR/:MINOR Docker aliases. # # Pre-conditions: -# - The rc tag must already exist as a GitHub prerelease (cut by semantic-release). -# - Smoke tests on the rc image must have passed (manual verification step). +# - The stable tag must already exist as a GitHub release (created automatically +# by semantic-release when the PR merges to main). +# - The immutable : Docker image must already be published and smoke-tested +# (done automatically by docker-release.yml on the release: published event). +# - Operator has verified the immutable image is stable (24h soak recommended). # # See docs/release-process.md for the full soak + promote procedure. on: workflow_dispatch: inputs: - rc_tag: + tag: description: > - Pre-release rc tag to promote, e.g. v7.80.0-rc.1. - Must already exist as a GitHub prerelease. + Stable tag to promote, e.g. v7.80.0. + Must already exist as a GitHub stable release with the immutable + Docker : image already published and smoke-tested. required: true type: string jobs: promote: - name: Promote ${{ inputs.rc_tag }} to stable + name: Promote ${{ inputs.tag }} Docker mutable tags runs-on: [self-hosted, linux, x64, cliproxy] permissions: - contents: write # required to edit GitHub releases + contents: read # to verify the release + actions: write # to dispatch docker-release.yml steps: - - name: Validate rc tag format + - name: Validate stable semver tag format run: | - if [[ "${{ inputs.rc_tag }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then - echo "[OK] Tag format valid: ${{ inputs.rc_tag }}" + if [[ "${{ inputs.tag }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "[OK] Tag format valid: ${{ inputs.tag }}" else - echo "[X] Expected vX.Y.Z-rc.N format, got: ${{ inputs.rc_tag }}" + echo "[X] Expected vX.Y.Z format, got: ${{ inputs.tag }}" + echo " For rc tags use docker-release.yml directly with promote_to_latest=true." exit 1 fi - - name: Verify release exists and is a prerelease + - name: Verify release exists and is stable (not a prerelease) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - IS_PRERELEASE=$(gh release view "${{ inputs.rc_tag }}" \ + IS_PRERELEASE=$(gh release view "${{ inputs.tag }}" \ --repo "${{ github.repository }}" \ --json isPrerelease --jq '.isPrerelease') - if [[ "${IS_PRERELEASE}" != "true" ]]; then - echo "[X] Release ${{ inputs.rc_tag }} is not a prerelease (isPrerelease=${IS_PRERELEASE})" - echo " Either it was already promoted, or the tag does not exist." + if [[ "${IS_PRERELEASE}" == "true" ]]; then + echo "[X] Release ${{ inputs.tag }} is still a prerelease (isPrerelease=true)" + echo " Semantic-release publishes stable releases to main automatically." + echo " Check that the tag was created from a main merge, not a dev prerelease." exit 1 fi - echo "[OK] Release ${{ inputs.rc_tag }} is a prerelease — proceeding with promotion" + if [[ "${IS_PRERELEASE}" != "false" ]]; then + echo "[X] Could not read release state for ${{ inputs.tag }} (got: ${IS_PRERELEASE})" + echo " Verify the tag exists: gh release view ${{ inputs.tag }} --repo ${{ github.repository }}" + exit 1 + fi + echo "[OK] Release ${{ inputs.tag }} is stable — proceeding with Docker mutable tag promotion" - - name: Promote release to stable + - name: Verify immutable Docker image exists + run: | + TAG_SANS_V="${{ inputs.tag }}" + TAG_SANS_V="${TAG_SANS_V#v}" + OWNER_LOWER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]') + IMAGE_REF="ghcr.io/${OWNER_LOWER}/ccs:${TAG_SANS_V}" + echo "[i] Verifying immutable image: ${IMAGE_REF}" + if ! docker manifest inspect "${IMAGE_REF}" > /dev/null 2>&1; then + echo "[X] Immutable image ${IMAGE_REF} not found in ghcr.io" + echo " Run docker-release.yml for this tag first, or wait for smoke tests to complete." + exit 1 + fi + echo "[OK] Immutable image ${IMAGE_REF} confirmed in registry" + + - name: Dispatch Docker mutable tag promotion env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh release edit "${{ inputs.rc_tag }}" \ + RUN_URL=$(gh workflow run "Publish Docker Image" \ --repo "${{ github.repository }}" \ - --prerelease=false \ - --latest - echo "[OK] Promoted ${{ inputs.rc_tag }} to stable (prerelease=false, latest=true)" - echo "[i] docker-release.yml will now trigger to add :latest/:MAJOR/:MINOR Docker tags" + --field "tag=${{ inputs.tag }}" \ + --field "promote_to_latest=true" \ + 2>&1) + echo "[OK] Dispatched docker-release.yml with tag=${{ inputs.tag }} promote_to_latest=true" + echo "[i] Monitor the run at: https://github.com/${{ github.repository }}/actions/workflows/docker-release.yml" + echo "[i] After the run completes, verify with:" + echo " docker buildx imagetools inspect ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/ccs:latest" diff --git a/.releaserc.cjs b/.releaserc.cjs index 8b39669a..56974338 100644 --- a/.releaserc.cjs +++ b/.releaserc.cjs @@ -3,7 +3,13 @@ * * Branch-aware config: * - dev branch: Uses dev release configuration (prerelease) - * - main branch: Uses production release configuration + * - main branch: Uses production release configuration (stable, npm @latest) + * + * RC soak window for Docker mutable tags is handled entirely in docker-release.yml: + * every release event publishes the immutable : Docker tag immediately; + * mutable :latest/:MAJOR/:MINOR tags require an explicit operator action via + * `gh workflow run promote-release.yml -f tag=vX.Y.Z` (workflow_dispatch). + * npm @latest is always set immediately on stable release — no rc soak needed. */ const currentBranch = @@ -93,18 +99,12 @@ const devConfig = { }; // Production release configuration -// Every merge to main auto-cuts as vX.Y.Z-rc.N (prerelease channel "rc"). -// A separate promote-release.yml workflow_dispatch promotes a specific rc tag -// to stable by flipping the GitHub release to non-prerelease, which triggers -// docker-release.yml to add the mutable :latest/:MAJOR/:MINOR Docker tags. -// See docs/release-process.md for the full soak + promote procedure. +// Every merge to main publishes a stable vX.Y.Z release immediately to npm @latest. +// Docker immutable : tag is pushed by docker-release.yml on the release: published event. +// Docker mutable :latest/:MAJOR/:MINOR tags require a separate manual promote step — see +// docs/release-process.md and promote-release.yml for the soak + promote procedure. const productionConfig = { - branches: [ - { - name: 'main', - prerelease: 'rc', - }, - ], + branches: ['main'], plugins: [ commitAnalyzer, releaseNotesGenerator, @@ -118,11 +118,9 @@ const productionConfig = { [ '@semantic-release/github', { - // rc releases are prerelease — use a minimal comment; stable promotion - // gets the full resolution comment via the promote-release workflow. successComment: - 'This issue is included in pre-release version ${nextRelease.version}. A stable release will follow after the rc soak period.', - releasedLabels: ['pending-release'], + ':tada: This issue has been resolved in version ${nextRelease.version} :tada:\n\nThe release is available on:\n- [npm package (@latest)](https://www.npmjs.com/package/@kaitranntt/ccs)\n- [GitHub release](${releases[0].url})', + releasedLabels: ['released'], }, ], [ diff --git a/docs/release-process.md b/docs/release-process.md index 072bd1b7..bdbd7ba2 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -1,35 +1,38 @@ # CCS Release Process -CCS uses a two-phase release model: every merge to `main` auto-cuts a -pre-release rc build, and a separate manual promotion step flips it to stable. -This gives a soak window before `:latest` Docker tags and npm `@latest` reach -end users. +CCS uses a decoupled release model: every merge to `main` immediately publishes +a stable npm `@latest` release and an immutable Docker `:` tag. Docker +mutable tags (`:latest`, `:`, `:`) require a separate manual +promote step after an operator-verified soak window. This decouples the npm +ecosystem from the Docker stability gate. -## Phase 1 — Automatic rc cut (on every merge to `main`) +## Phase 1 — Automatic stable release (on every merge to `main`) 1. A PR is merged into `main` with a conventional commit (`feat:`, `fix:`, etc.). 2. `release.yml` triggers semantic-release, which reads `.releaserc.cjs`. -3. Because `main` is configured as `{ name: 'main', prerelease: 'rc' }`, - semantic-release cuts a GitHub pre-release tagged `vX.Y.Z-rc.N`. -4. The npm package is published to the `rc` dist-tag - (`npm install @kaitranntt/ccs@rc`). -5. `docker-release.yml` triggers on the `release: published` event and: +3. Because `main` is a stable channel, semantic-release cuts a GitHub release + tagged `vX.Y.Z` and publishes the npm package to the `@latest` dist-tag + immediately. No rc channel, no soak delay on npm. +4. `docker-release.yml` triggers on the `release: published` event and: + - Validates the tag as stable semver (`vX.Y.Z`). - Builds the integrated image for `linux/amd64` and `linux/arm64`. - - Pushes **only the immutable** `ghcr.io/kaitranntt/ccs:X.Y.Z-rc.N` tag. + - Pushes **only the immutable** `ghcr.io/kaitranntt/ccs:X.Y.Z` tag. - Signs the image with cosign (keyless OIDC). - Runs smoke tests (`smoke-test` job). - Mutable tags (`:latest`, `:`, `:`) are **not** added at - this stage — `promote-mutable-tags` is gated on `!github.event.release.prerelease`. + this stage — `promote-mutable-tags` only runs on explicit + `workflow_dispatch` with `promote_to_latest=true`. -## Phase 2 — Manual promotion to stable +## Phase 2 — Manual promotion to Docker mutable tags (rc.1 soak window) -After the rc image has soaked (typically 24–48 h with no reported issues): +After the immutable `:` Docker image has soaked (typically 24 h with no +reported issues), the operator promotes mutable tags: -1. Verify the rc image is healthy: +1. Verify the immutable image is healthy: ```bash - docker pull ghcr.io/kaitranntt/ccs:X.Y.Z-rc.N - docker run --rm -p 3000:3000 -p 8317:8317 ghcr.io/kaitranntt/ccs:X.Y.Z-rc.N + docker pull ghcr.io/kaitranntt/ccs:X.Y.Z + docker run --rm -p 3000:3000 -p 8317:8317 ghcr.io/kaitranntt/ccs:X.Y.Z # check http://localhost:3000 and http://localhost:8317 ``` @@ -39,25 +42,37 @@ After the rc image has soaked (typically 24–48 h with no reported issues): cosign verify \ --certificate-identity-regexp "https://github.com/kaitranntt/ccs/.github/workflows/docker-release.yml" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - ghcr.io/kaitranntt/ccs:X.Y.Z-rc.N + ghcr.io/kaitranntt/ccs:X.Y.Z ``` 3. Run the `promote-release` workflow via GitHub Actions UI or CLI: ```bash gh workflow run promote-release.yml \ - --field rc_tag=vX.Y.Z-rc.N + --field tag=vX.Y.Z ``` -4. The workflow calls `gh release edit vX.Y.Z-rc.N --prerelease=false --latest`, - which fires a new `release: published` event with `prerelease=false`. + This dispatches `docker-release.yml` with `promote_to_latest=true`, which + triggers the `promote-mutable-tags` job to add `:latest`, `:`, and + `:` via `docker buildx imagetools create`. -5. `docker-release.yml` picks up the stable event and the `promote-mutable-tags` - job runs, adding `:latest`, `:`, `:` via - `docker buildx imagetools create`. + Alternatively, dispatch `docker-release.yml` directly: -6. npm `@latest` is already set by semantic-release during the rc phase for the - stable semver portion — no separate npm step is needed. + ```bash + gh workflow run "Publish Docker Image" \ + --field tag=vX.Y.Z \ + --field promote_to_latest=true + ``` + +## Why npm and Docker have different soak windows + +- **npm `@latest`**: Published immediately on every `main` merge. npm users who + pin a version are unaffected; users who run `npm install -g @kaitranntt/ccs` + get the latest immediately. Rollback is `npm install -g @kaitranntt/ccs@X.Y.Z`. +- **Docker `:latest`**: Promoted only after operator confirmation. Users who + pull `:latest` or run `docker pull` without a pinned tag are shielded from + a bad image. The immutable `:` tag is always available for pinned usage + from the moment of release. ## Verifying the promotion @@ -65,7 +80,7 @@ After the rc image has soaked (typically 24–48 h with no reported issues): # Confirm :latest points to the promoted digest docker buildx imagetools inspect ghcr.io/kaitranntt/ccs:latest -# Confirm npm @latest updated +# Confirm npm @latest updated (happens automatically at Phase 1) npm view @kaitranntt/ccs dist-tags ``` @@ -74,19 +89,18 @@ npm view @kaitranntt/ccs dist-tags If a promoted release is found to be bad: ```bash -# Re-mark as prerelease on GitHub (stops new users from pulling :latest via UI) -gh release edit vX.Y.Z-rc.N --prerelease=true --latest=false - -# Repoint :latest to the previous known-good version +# Repoint :latest to the previous known-good immutable tag docker buildx imagetools create \ --tag ghcr.io/kaitranntt/ccs:latest \ ghcr.io/kaitranntt/ccs:PREVIOUS.VERSION + +# For npm, publish a fix as a new patch release (do not unpublish) +# Unpublishing npm packages causes downstream breakage for pinned consumers. ``` ## Branch / tag taxonomy -| Branch | Semantic-release channel | npm dist-tag | Docker tag | -|--------|--------------------------|--------------|------------| -| `main` | `rc` prerelease | `@rc` | `:-rc.N` (immutable only) | -| `main` (after promote) | stable | `@latest` | `:latest`, `:`, `:`, `:` | -| `dev` | `dev` prerelease | `@dev` | not published | +| Branch | Semantic-release channel | npm dist-tag | Docker tag (on release event) | Docker mutable (on promote) | +|--------|--------------------------|--------------|-------------------------------|------------------------------| +| `main` | stable | `@latest` | `:` (immutable, immediate) | `:latest`, `:`, `:` (after soak) | +| `dev` | `dev` prerelease | `@dev` | not published | not published |