From c2dfd289c2fa584017879b60bf581f50c7ff5634 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sat, 12 Sep 2026 11:31:33 -0400 Subject: [PATCH] fix(ci): listen for the cli-release dispatch and publish in lockstep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nself-org/cli release.yml has dispatched `cli-release` at this repo on every stable release since S34-T12, to satisfy the MASTER-VERSIONS Hard Rule: "When CLI tags vX.Y.Z, Admin's Docker image nself/nself-admin:X.Y.Z must publish on the same release day. A release is not complete until CLI binary + homebrew formula + admin Docker image are all published at the matching version and :latest points to the new version." No workflow here listened for it. All 20 were checked; docker-publish.yml and release.yml both trigger on `push: tags: v*` only. So the dispatch went into the void on every release, and Docker Hub has sat at 1.0.13 while CLI reached 1.3.6 — the lockstep rule has been violated in practice, as MASTER-VERSIONS already records. Three changes: 1. Listen for `repository_dispatch: [cli-release]`. 2. Resolve the version per trigger. The old logic fell through to `${GITHUB_REF#refs/tags/v}` for anything that was not workflow_dispatch. On a repository_dispatch GITHUB_REF is refs/heads/main, so VERSION would have become the literal "refs/heads/main". It now switches on GITHUB_EVENT_NAME, strips the tag's leading v, and validates plain X.Y.Z semver before the value can reach a Docker tag or the :latest alias. The payload arrives via env: rather than inline interpolation — a dispatch payload is controlled by whatever holds the dispatching token. 3. Refuse to publish on a lockstep break. If the CLI released vX.Y.Z but admin's package.json still says something else, the image would be tagged with a version its own code does not report. MASTER-VERSIONS: "Any desync ... is a hard error." scripts/check-version-lockstep.sh already proves package.json and cli-version.ts agree with each other; this proves they agree with the release being cut. Verified by simulating all four trigger paths: dispatch with "v1.3.6" and "1.3.6", tag push, and manual input all resolve to 1.3.6 / major 1 / major_minor 1.3. The empty-payload case that previously yielded "refs/heads/main", a shell-injection attempt, and a prerelease tag are all rejected. No prerelease tag has ever been cut in this repo, so the strict X.Y.Z check removes no path in use. Note for the first run: docker-publish has failed on every attempt since 2026-05-26, most recently on the Trivy CRITICAL gate for npm's bundled tar 7.5.11 (CVE-2026-59873). That was fixed in d19a32c by removing npm from the runtime image, which landed AFTER the last run — so the gate has not been exercised since the fix. This PR makes the path live; the first dispatch is what proves it. --- .github/workflows/docker-publish.yml | 73 ++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 41377c05..c54af4d6 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,12 +1,26 @@ name: Docker — Build & Publish nself-admin # Separate from release.yml — this workflow handles Docker Hub publish only -# Triggers on: release tag push OR manual dispatch +# Triggers on: release tag push, CLI release dispatch, OR manual dispatch +# +# CLI <-> Admin lockstep (S34-T12, MASTER-VERSIONS.md Hard Rule): +# "When CLI tags vX.Y.Z, Admin's Docker image nself/nself-admin:X.Y.Z must +# publish on the same release day. A release is not complete until CLI +# binary + homebrew formula + admin Docker image are all published at the +# matching version and :latest points to the new version." +# +# nself-org/cli release.yml has dispatched `cli-release` here since S34-T12 to +# satisfy that rule. Nothing in this repo listened for it, so the dispatch went +# into the void on every release and the image was never rebuilt — Docker Hub +# sat at 1.0.13 while CLI reached 1.3.6. The repository_dispatch trigger below +# is the missing half. on: push: tags: - 'v*' + repository_dispatch: + types: [cli-release] workflow_dispatch: inputs: version: @@ -31,12 +45,33 @@ jobs: - name: Resolve version id: version + env: + # Passed through env, never interpolated straight into the script: a + # repository_dispatch payload is controlled by whatever holds the + # dispatching token, so it is untrusted input. + INPUT_VERSION: ${{ github.event.inputs.version }} + PAYLOAD_VERSION: ${{ github.event.client_payload.version }} run: | - if [ -n "${{ github.event.inputs.version }}" ]; then - VERSION="${{ github.event.inputs.version }}" - else - VERSION="${GITHUB_REF#refs/tags/v}" + set -euo pipefail + case "${GITHUB_EVENT_NAME}" in + workflow_dispatch) VERSION="${INPUT_VERSION}" ;; + repository_dispatch) VERSION="${PAYLOAD_VERSION}" ;; + push) VERSION="${GITHUB_REF#refs/tags/v}" ;; + *) echo "::error::unsupported trigger: ${GITHUB_EVENT_NAME}"; exit 1 ;; + esac + + # The cli dispatch sends the tag ("v1.3.6"); tags and inputs may not. + VERSION="${VERSION#v}" + + # Validate before anything can reach a Docker tag. Without this a + # trigger that supplies no version silently produced "refs/heads/main" + # as $VERSION and would have pushed nonsense over the :latest tag. + if ! printf '%s' "${VERSION}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::resolved version '${VERSION}' is not a plain X.Y.Z semver" + echo " trigger: ${GITHUB_EVENT_NAME}" + exit 1 fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "major=$(echo $VERSION | cut -d. -f1)" >> "$GITHUB_OUTPUT" # major_minor already includes the major segment (1.3), so the tag @@ -45,6 +80,34 @@ jobs: echo "major_minor=$(echo $VERSION | cut -d. -f1-2)" >> "$GITHUB_OUTPUT" echo "Building nself-admin v$VERSION" + - name: Verify CLI/Admin lockstep before publishing + # Only on the dispatch path: the CLI has just released vX.Y.Z and this + # image is about to be published as X.Y.Z. If admin's source has not + # been bumped to match, the image would carry a version its code does + # not implement. MASTER-VERSIONS.md: "Any desync ... is a hard error." + # + # scripts/check-version-lockstep.sh (run on every push/PR by + # version-lockstep.yml) proves package.json and cli-version.ts agree + # with each other; this proves they agree with the release being cut. + if: github.event_name == 'repository_dispatch' + env: + RELEASE_VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + PKG_VERSION="$(node -p "require('./package.json').version")" + echo "cli release : ${RELEASE_VERSION}" + echo "package.json: ${PKG_VERSION}" + if [ "${PKG_VERSION}" != "${RELEASE_VERSION}" ]; then + echo "::error::CLI/Admin lockstep break — refusing to publish" + echo " CLI released v${RELEASE_VERSION} but admin package.json is ${PKG_VERSION}." + echo " Publishing now would tag nself/nself-admin:${RELEASE_VERSION} with code" + echo " that still identifies itself as ${PKG_VERSION}." + echo " Bump package.json + src/lib/cli-version.ts to ${RELEASE_VERSION}, merge," + echo " then re-run this workflow via workflow_dispatch with version=${RELEASE_VERSION}." + exit 1 + fi + echo "Lockstep verified: admin source matches CLI v${RELEASE_VERSION}" + - name: Resolve CLI version for Dockerfile id: cli_version env: