From 5a930319ba9da2e6a22866aeac8f0075448a2835 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 11:27:09 -0400 Subject: [PATCH 1/2] ci: gate and build main too (:main image); :latest stays release-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously main pushes were deliberately skipped — CI only ran on dev and v* tags. This conflicted with the intended policy (CI on dev AND main). Now main is a first-class gated, built line: dev->:dev, main->:main, v* tag->:latest + :, every build also tagged with the commit sha. Per-ref concurrency already supersedes rapid pushes, so dev and main run independently without stacking identical work. Co-Authored-By: Claude Opus 4.8 (1M context) --- .forgejo/workflows/ci.yml | 48 ++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 8e3b446..75c25f0 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -1,12 +1,19 @@ # CI runs first; build only proceeds if all checks pass. # -# Push to dev: typecheck + lint + test + build :dev + : -# Tag v* (release): typecheck + lint + test + build :latest + : + : +# Push to dev: typecheck + lint + test + build :dev + : +# Push to main: typecheck + lint + test + build :main + : +# Tag v* (release): typecheck + lint + test + build :latest + : + : # -# main pushes are NOT gated here: a merge to main only happens after -# dev has already passed CI, and the release tag is the sole trigger -# for a production image. Re-running CI on the merge commit just burns -# runner time without changing the outcome. +# Both dev and main are gated AND built: dev is the working line, main the +# protected/integration line, each its own deployable image (:dev / :main). +# Running CI again on the main merge commit is intentional — main is +# validated and built in its own right. The v* release tag is the ONLY +# trigger that publishes :latest (the production pointer) plus the +# immutable : tag. +# +# Successive pushes to the SAME ref supersede each other (see concurrency +# below), so rapid pushes don't stack identical work; dev and main runs are +# independent refs and never cancel one another. # # To cut a release: # Create a release via the Forgejo UI on main with a v* tag name. @@ -16,11 +23,8 @@ # gating on branch push is already enough. # # NOTE on the `if:` guards below: Forgejo Actions does not consistently -# honor `on.push.branches` as a filter — merge commits landing on main -# still trigger the workflow, producing redundant runs on the same SHA -# that was already gated on dev. Every job therefore repeats the ref -# check so main pushes trigger the workflow but every job skips -# immediately (no runner time, no duplicate work). +# honor `on.push.branches` as a filter, so every job repeats the ref check +# explicitly — permitting dev, main, and v* tags, rejecting anything else. # # Required secrets (repo → Settings → Secrets → Actions): # REGISTRY_USER — your Forgejo username @@ -29,7 +33,7 @@ name: CI & Build on: push: - branches: [dev] + branches: [dev, main] tags: ["v*"] paths: - "src/**" @@ -67,8 +71,8 @@ env: jobs: typecheck: name: TypeScript typecheck - # Skip on main merge-commit pushes — see workflow header comment. - if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v') + # Gate dev, main, and v* tags; reject any other ref (see header note). + if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 @@ -92,7 +96,7 @@ jobs: lint: name: Python lint - if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v') + if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 @@ -106,7 +110,7 @@ jobs: test: name: Python tests - if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v') + if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 @@ -138,11 +142,9 @@ jobs: build: name: Build & push image needs: [typecheck, lint, test] - # Build on dev branch pushes and version tag pushes only. - # Mirrors the ref guard on the gate jobs above — main merge-commit - # pushes skip here too, so no production image is ever built from a - # raw main push (only from the v* tag the release creates). - if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v') + # Build on dev, main, and v* tag pushes. dev → :dev, main → :main, + # tag → :latest + :; every build also gets an immutable :. + if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 @@ -168,6 +170,10 @@ jobs: refs/heads/dev) TAGS="$TAGS,${{ env.IMAGE }}:dev" ;; + refs/heads/main) + TAGS="$TAGS,${{ env.IMAGE }}:main" + BUILD_VERSION="main" + ;; refs/tags/*) TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}" BUILD_VERSION="${{ github.ref_name }}" From 9a0d5f3109cfca48602e817de557c8050075a47b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 11:52:21 -0400 Subject: [PATCH 2/2] =?UTF-8?q?ci:=20drop=20the=20:main=20tag=20=E2=80=94?= =?UTF-8?q?=20main=20builds=20publish=20only=20the=20immutable=20:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit :latest (release-only) is the single production pointer; a :main moving tag just duplicated it. main pushes still gate + build (the : image is the rollback point), but no longer publish a :main alias. The tag was new and unreferenced, so nothing depends on it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .forgejo/workflows/ci.yml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 75c25f0..ba50226 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -1,15 +1,15 @@ # CI runs first; build only proceeds if all checks pass. # # Push to dev: typecheck + lint + test + build :dev + : -# Push to main: typecheck + lint + test + build :main + : +# Push to main: typecheck + lint + test + build : (no moving tag) # Tag v* (release): typecheck + lint + test + build :latest + : + : # -# Both dev and main are gated AND built: dev is the working line, main the -# protected/integration line, each its own deployable image (:dev / :main). -# Running CI again on the main merge commit is intentional — main is -# validated and built in its own right. The v* release tag is the ONLY -# trigger that publishes :latest (the production pointer) plus the -# immutable : tag. +# Both dev and main are gated AND built. dev pushes move the :dev tag; main +# pushes publish only the immutable : image — no :main tag, because +# :latest (release-only) is the single production pointer and a :main alias +# would just duplicate it. Running CI on the main merge commit is intentional: +# main is validated and its : image is the rollback point. The v* release +# tag is the ONLY trigger that publishes :latest plus the immutable :. # # Successive pushes to the SAME ref supersede each other (see concurrency # below), so rapid pushes don't stack identical work; dev and main runs are @@ -142,7 +142,7 @@ jobs: build: name: Build & push image needs: [typecheck, lint, test] - # Build on dev, main, and v* tag pushes. dev → :dev, main → :main, + # Build on dev, main, and v* tag pushes. dev → :dev, main → (sha only), # tag → :latest + :; every build also gets an immutable :. if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') runs-on: python-ci @@ -171,7 +171,8 @@ jobs: TAGS="$TAGS,${{ env.IMAGE }}:dev" ;; refs/heads/main) - TAGS="$TAGS,${{ env.IMAGE }}:main" + # main publishes only the immutable : image (set above) — + # no :main tag; :latest (release-only) is the production pointer. BUILD_VERSION="main" ;; refs/tags/*)