From 38991668312e4ed2b46a098e2590f474e06d0d9a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:31:43 +0000 Subject: [PATCH 1/3] chore(ci): bump actions/checkout from 5 to 7 Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/beta.yml | 4 ++-- .github/workflows/brand-cleanup.yml | 2 +- .github/workflows/bundled-driver-pin.yml | 2 +- .github/workflows/changeset-check.yml | 2 +- .github/workflows/debian-base-currency.yml | 2 +- .github/workflows/optimizer-release.yml | 8 ++++---- .github/workflows/release-assets.yml | 4 ++-- .github/workflows/release.yml | 2 +- .github/workflows/repo-hygiene.yml | 2 +- .github/workflows/rpi-image-build.yml | 4 ++-- .github/workflows/test.yml | 24 +++++++++++----------- 11 files changed, 28 insertions(+), 28 deletions(-) diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 71542d2b..051d70d4 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -25,7 +25,7 @@ jobs: state_schema: ${{ steps.version.outputs.state_schema }} steps: - name: Checkout selected commit - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -84,7 +84,7 @@ jobs: image_suffix: "-updater" steps: - name: Checkout beta tag - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ needs.tag.outputs.version }} diff --git a/.github/workflows/brand-cleanup.yml b/.github/workflows/brand-cleanup.yml index 7253e2d5..49b9607f 100644 --- a/.github/workflows/brand-cleanup.yml +++ b/.github/workflows/brand-cleanup.yml @@ -32,7 +32,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/bundled-driver-pin.yml b/.github/workflows/bundled-driver-pin.yml index 5249da9a..565f0a72 100644 --- a/.github/workflows/bundled-driver-pin.yml +++ b/.github/workflows/bundled-driver-pin.yml @@ -35,7 +35,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v7 - name: Check the snapshot and comparison order run: bash scripts/test-bundled-driver-pin-order.sh diff --git a/.github/workflows/changeset-check.yml b/.github/workflows/changeset-check.yml index fb45426d..dc3f4e0f 100644 --- a/.github/workflows/changeset-check.yml +++ b/.github/workflows/changeset-check.yml @@ -76,7 +76,7 @@ jobs: fi - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/debian-base-currency.yml b/.github/workflows/debian-base-currency.yml index 21815a86..0964f57a 100644 --- a/.github/workflows/debian-base-currency.yml +++ b/.github/workflows/debian-base-currency.yml @@ -36,7 +36,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v7 - name: Compare the pinned suite against Debian stable id: compare diff --git a/.github/workflows/optimizer-release.yml b/.github/workflows/optimizer-release.yml index 9576c434..2ee75f99 100644 --- a/.github/workflows/optimizer-release.yml +++ b/.github/workflows/optimizer-release.yml @@ -32,7 +32,7 @@ jobs: alias: ${{ steps.version.outputs.alias }} image_exists: ${{ steps.version.outputs.image_exists }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 with: fetch-depth: 0 - uses: actions/setup-python@v6 @@ -139,7 +139,7 @@ jobs: needs: validate runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: actions/setup-python@v6 with: python-version: '3.12' @@ -170,7 +170,7 @@ jobs: if: needs.validate.outputs.image_exists != 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 with: ref: ${{ github.sha }} - uses: docker/setup-qemu-action@v4 @@ -212,7 +212,7 @@ jobs: }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 with: fetch-depth: 0 - env: diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index a9b003d0..e76f9a1b 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -107,7 +107,7 @@ jobs: archive: zip steps: - name: Checkout tag - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: # Check out the tag so -X main.Version matches what users install. ref: ${{ needs.meta.outputs.tag }} @@ -250,7 +250,7 @@ jobs: description: Sidecar that executes docker compose pull + up for in-app updates steps: - name: Checkout tag - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ needs.meta.outputs.tag }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d0b11df1..bafe7d53 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,7 +66,7 @@ jobs: version: ${{ steps.publish.outputs.version }} steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: # changesets/action needs full history so it can detect # which changesets have already been consumed by past diff --git a/.github/workflows/repo-hygiene.yml b/.github/workflows/repo-hygiene.yml index a0dd81dd..21573cee 100644 --- a/.github/workflows/repo-hygiene.yml +++ b/.github/workflows/repo-hygiene.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/rpi-image-build.yml b/.github/workflows/rpi-image-build.yml index 592f96e5..fee90b25 100644 --- a/.github/workflows/rpi-image-build.yml +++ b/.github/workflows/rpi-image-build.yml @@ -59,7 +59,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v7 - name: Validate shell syntax run: | @@ -124,7 +124,7 @@ jobs: df -h / - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ inputs.ref || github.sha }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4ee883e6..3d42b58d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,7 +28,7 @@ jobs: base_sha: ${{ steps.paths.outputs.base_sha }} head_sha: ${{ steps.paths.outputs.head_sha }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 with: fetch-depth: 0 - id: paths @@ -114,7 +114,7 @@ jobs: if: needs.changes.outputs.core == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: go-version: '1.26' @@ -140,7 +140,7 @@ jobs: if: needs.changes.outputs.optimizer == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: actions/setup-python@v6 with: python-version: '3.12' @@ -168,7 +168,7 @@ jobs: if: needs.changes.outputs.web == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: '22' @@ -188,7 +188,7 @@ jobs: if: needs.changes.outputs.drivers == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: go-version: '1.26' @@ -238,7 +238,7 @@ jobs: if: needs.changes.outputs.drivers == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 with: fetch-depth: 0 - uses: actions/setup-go@v7 @@ -288,7 +288,7 @@ jobs: env: DEVICE_SUPPORT_TOKEN: ${{ secrets.SOURCEFUL_CI_REPO_TOKEN }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: astral-sh/setup-uv@v7 if: env.DEVICE_SUPPORT_TOKEN != '' with: @@ -318,7 +318,7 @@ jobs: - name: Check out canonical Device Support package source if: env.DEVICE_SUPPORT_TOKEN != '' - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: repository: ${{ steps.baseline.outputs.repository }} ref: ${{ steps.baseline.outputs.commit }} @@ -398,7 +398,7 @@ jobs: if: needs.changes.outputs.compose == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - run: make compose-migration-test container-boundary-test # contract/registry.yaml is one file that lives in this repository and in @@ -414,7 +414,7 @@ jobs: name: the registry has not drifted from the app runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 # A change that lands in both repositories at once cannot be compared # against the other one's default branch: the counterpart is not there @@ -448,7 +448,7 @@ jobs: # One file, not the app's whole history. - name: Check out the app - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: repository: srcfl/ftw-webapp ref: ${{ steps.pair.outputs.ref }} @@ -494,7 +494,7 @@ jobs: if: needs.changes.outputs.core == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: go-version: '1.26' From 3f397e7d191fa9325e096dabeb55eac4cb5812ea Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 16 Aug 2026 14:33:21 +0200 Subject: [PATCH 2/3] fix(release): promote exact beta image manifests --- .changeset/exact-beta-stable-promotion.md | 5 + .github/workflows/beta.yml | 414 ++++++++++++++++++++-- .github/workflows/release-assets.yml | 255 +++++++++---- .github/workflows/release.yml | 83 ++++- Dockerfile | 3 +- Makefile | 8 +- docker-compose.macos.yml | 3 + docker-compose.yml | 4 + docs/self-update.md | 21 +- go/cmd/ftw/main.go | 70 ++++ go/cmd/ftw/release_version_test.go | 33 ++ scripts/enable-modular-stack.sh | 1 + scripts/migrate-legacy-compose.sh | 66 +++- scripts/test-exact-image-promotion.sh | 109 ++++++ scripts/test-modular-compose.sh | 37 ++ 15 files changed, 981 insertions(+), 131 deletions(-) create mode 100644 .changeset/exact-beta-stable-promotion.md create mode 100644 go/cmd/ftw/release_version_test.go create mode 100755 scripts/test-exact-image-promotion.sh diff --git a/.changeset/exact-beta-stable-promotion.md b/.changeset/exact-beta-stable-promotion.md new file mode 100644 index 00000000..1fd643c9 --- /dev/null +++ b/.changeset/exact-beta-stable-promotion.md @@ -0,0 +1,5 @@ +--- +"forty-two-watts": patch +--- + +Promote the validated Core and updater beta images to stable without rebuilding them, while keeping the running release tag correct on both channels. diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 051d70d4..5a44f251 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -13,7 +13,9 @@ permissions: packages: write concurrency: - group: beta-${{ inputs.version }} + # Versioned tags are immutable, while every completed candidate may move the + # shared :beta aliases. Serialize the full flow so aliases cannot race. + group: beta-release-channel cancel-in-progress: false jobs: @@ -22,7 +24,10 @@ jobs: runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} + runtime_version: ${{ steps.version.outputs.runtime_version }} + oci_version: ${{ steps.version.outputs.oci_version }} state_schema: ${{ steps.version.outputs.state_schema }} + created: ${{ steps.version.outputs.created }} steps: - name: Checkout selected commit uses: actions/checkout@v7 @@ -46,6 +51,10 @@ jobs: echo "${TAG} does not match package.json version ${PACKAGE_VERSION}" >&2 exit 1 fi + # Build the future stable identity now. Stable promotion copies this + # exact manifest; docker-compose supplies the immutable beta tag to + # the process while a site is validating the candidate. + RUNTIME_VERSION="v${PACKAGE_VERSION}" STATE_SCHEMA="$(node -p "require('./state-schema.json').version")" if [[ ! "${STATE_SCHEMA}" =~ ^[1-9][0-9]*$ ]]; then echo "state-schema.json must contain a positive integer version" >&2 @@ -54,6 +63,7 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + TAG_CREATED=false if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then git fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}" TAG_COMMIT="$(git rev-list -n 1 "${TAG}")" @@ -64,14 +74,191 @@ jobs: else git tag -a "${TAG}" -m "Beta release ${TAG}" "${GITHUB_SHA}" git push origin "refs/tags/${TAG}" + TAG_CREATED=true fi echo "version=${TAG}" >> "${GITHUB_OUTPUT}" + echo "runtime_version=${RUNTIME_VERSION}" >> "${GITHUB_OUTPUT}" + echo "oci_version=${PACKAGE_VERSION}" >> "${GITHUB_OUTPUT}" echo "state_schema=${STATE_SCHEMA}" >> "${GITHUB_OUTPUT}" + echo "created=${TAG_CREATED}" >> "${GITHUB_OUTPUT}" + + candidate: + name: guard immutable beta candidate + runs-on: ubuntu-latest + needs: tag + outputs: + receipt_found: ${{ steps.guard.outputs.receipt_found }} + core_build: ${{ steps.guard.outputs.core_build }} + core_digest: ${{ steps.guard.outputs.core_digest }} + updater_build: ${{ steps.guard.outputs.updater_build }} + updater_digest: ${{ steps.guard.outputs.updater_digest }} + steps: + - name: Checkout beta tag + uses: actions/checkout@v7 + with: + ref: ${{ needs.tag.outputs.version }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to canonical GHCR namespace + uses: docker/login-action@v4.6.0 + with: + registry: ghcr.io + username: ${{ vars.SOURCEFUL_GHCR_USER || github.actor }} + password: ${{ secrets.SOURCEFUL_GHCR_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Refuse to move a published candidate + id: guard + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.tag.outputs.version }} + run: | + set -euo pipefail + STABLE_VERSION="${TAG#v}" + STABLE_VERSION="${STABLE_VERSION%-beta.*}" + + mkdir -p receipt + release_json="${RUNNER_TEMP}/beta-release.json" + release_status="$(curl -sS -L -o "${release_json}" -w '%{http_code}' \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}")" + case "${release_status}" in + 200) + receipt_count="$(jq '[.assets[] | select(.name == "ftw-image-digests.json")] | length' "${release_json}")" + if [ "${receipt_count}" -gt 1 ]; then + echo "${TAG} has duplicate digest receipts; refusing to recover it." >&2 + exit 1 + fi + ;; + 404) + receipt_count=0 + ;; + *) + echo "Could not determine whether ${TAG} has a digest receipt (HTTP ${release_status}); refusing to publish." >&2 + exit 1 + ;; + esac + + if [ "${receipt_count}" -eq 1 ]; then + receipt_url="$(jq -er '.assets[] | select(.name == "ftw-image-digests.json") | .url' "${release_json}")" + curl -fsSL -L \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H 'Accept: application/octet-stream' \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + "${receipt_url}" > receipt/ftw-image-digests.json + record="receipt/ftw-image-digests.json" + jq -e \ + --arg tag "${TAG}" \ + --arg commit "${GITHUB_SHA}" \ + --arg version "${STABLE_VERSION}" ' + .schema == 1 + and .tag == $tag + and .commit == $commit + and .stable_version == $version + and (.images.core.digest | test("^sha256:[0-9a-f]{64}$")) + and (.images.updater.digest | test("^sha256:[0-9a-f]{64}$")) + ' "${record}" >/dev/null + + for item in \ + "core|ghcr.io/srcfl/ftw:${TAG}" \ + "updater|ghcr.io/srcfl/ftw-updater:${TAG}"; do + key="${item%%|*}" + image="${item#*|}" + recorded="$(jq -r --arg key "${key}" '.images[$key].digest' "${record}")" + current="$(scripts/inspect-image-digest.sh "${image}")" + if [ "${current}" != "${recorded}" ]; then + echo "${image} moved after publication: recorded ${recorded}, current ${current}." >&2 + exit 1 + fi + echo "${key}_build=false" >> "${GITHUB_OUTPUT}" + echo "${key}_digest=${recorded}" >> "${GITHUB_OUTPUT}" + done + echo "receipt_found=true" >> "${GITHUB_OUTPUT}" + echo "Reusing completed immutable ${TAG}." + exit 0 + fi + + echo "receipt_found=false" >> "${GITHUB_OUTPUT}" + + manifest_status() { + local repository="$1" + local token http_status + token="$(curl -fsSL --get \ + --data-urlencode service=ghcr.io \ + --data-urlencode "scope=repository:${repository}:pull" \ + https://ghcr.io/token | jq -er '.token')" + http_status="$(curl -sS -o /dev/null -w '%{http_code}' -I \ + -H "Authorization: Bearer ${token}" \ + -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \ + "https://ghcr.io/v2/${repository}/manifests/${TAG}")" + printf '%s\n' "${http_status}" + } + + validate_existing_component() { + local key="$1" + local image="$2" + local digest metadata + digest="$(scripts/inspect-image-digest.sh "${image}")" + metadata="${RUNNER_TEMP}/${key}-unfinished.json" + docker buildx imagetools inspect "${image}@${digest}" \ + --format '{{json .Image}}' > "${metadata}" + python3 - "${metadata}" "${GITHUB_SHA}" "${STABLE_VERSION}" "${image}" <<'PY' + import json + import sys + + path, expected_revision, expected_version, image_ref = sys.argv[1:] + with open(path, encoding="utf-8") as source: + images = json.load(source) + for platform in ("linux/amd64", "linux/arm64"): + image = images.get(platform) + if image is None: + raise SystemExit(f"{image_ref} is missing required platform {platform}") + labels = image.get("config", {}).get("Labels", {}) + revision = labels.get("org.opencontainers.image.revision") + version = labels.get("org.opencontainers.image.version") + if revision != expected_revision or version != expected_version: + raise SystemExit( + f"{image_ref} metadata mismatch for {platform}: " + f"revision={revision!r} version={version!r}" + ) + PY + echo "${key}_build=false" >> "${GITHUB_OUTPUT}" + echo "${key}_digest=${digest}" >> "${GITHUB_OUTPUT}" + echo "Reusing verified unfinished component ${image}@${digest}." + } + + for item in \ + "core|srcfl/ftw|ghcr.io/srcfl/ftw:${TAG}" \ + "updater|srcfl/ftw-updater|ghcr.io/srcfl/ftw-updater:${TAG}"; do + key="${item%%|*}" + remainder="${item#*|}" + repository="${remainder%%|*}" + image="${remainder#*|}" + http_status="$(manifest_status "${repository}")" + case "${http_status}" in + 404) + echo "${key}_build=true" >> "${GITHUB_OUTPUT}" + echo "${key}_digest=" >> "${GITHUB_OUTPUT}" + echo "Building truly absent unfinished component ${image}." + ;; + 200) + validate_existing_component "${key}" "${image}" + ;; + *) + echo "Could not prove ghcr.io/${repository}:${TAG} is absent (HTTP ${http_status}); refusing to publish." >&2 + exit 1 + ;; + esac + done docker: name: beta docker (${{ matrix.target }}) runs-on: ubuntu-latest - needs: tag + needs: [tag, candidate] + if: ${{ needs.candidate.outputs.receipt_found != 'true' }} strategy: fail-fast: false matrix: @@ -79,9 +266,13 @@ jobs: - target: main dockerfile: ./Dockerfile image_suffix: "" + build_required: ${{ needs.candidate.outputs.core_build }} + existing_digest: ${{ needs.candidate.outputs.core_digest }} - target: updater dockerfile: ./Dockerfile.updater image_suffix: "-updater" + build_required: ${{ needs.candidate.outputs.updater_build }} + existing_digest: ${{ needs.candidate.outputs.updater_digest }} steps: - name: Checkout beta tag uses: actions/checkout@v7 @@ -92,9 +283,11 @@ jobs: # drivers/BUNDLED_SOURCE.json. The image copies it, so it has to exist # in the build context before buildx runs. - name: Fetch the bundled drivers + if: ${{ matrix.build_required == 'true' }} run: make drivers - name: Set up QEMU + if: ${{ matrix.build_required == 'true' }} uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx @@ -108,6 +301,7 @@ jobs: password: ${{ secrets.SOURCEFUL_GHCR_TOKEN || secrets.GITHUB_TOKEN }} - name: Build and publish beta image + if: ${{ matrix.build_required == 'true' }} id: build uses: docker/build-push-action@v7 with: @@ -117,19 +311,41 @@ jobs: push: true tags: | ghcr.io/srcfl/ftw${{ matrix.image_suffix }}:${{ needs.tag.outputs.version }} - ghcr.io/srcfl/ftw${{ matrix.image_suffix }}:beta labels: | org.opencontainers.image.source=https://github.com/srcfl/ftw org.opencontainers.image.revision=${{ github.sha }} - org.opencontainers.image.version=${{ needs.tag.outputs.version }} + org.opencontainers.image.version=${{ needs.tag.outputs.oci_version }} org.opencontainers.image.licenses=Apache-2.0 build-args: | - VERSION=${{ needs.tag.outputs.version }} + VERSION=${{ needs.tag.outputs.runtime_version }} + CANDIDATE_TAG=${{ needs.tag.outputs.version }} BUILD_SHA=${{ github.sha }} # Reuse the same content-addressed layers as stable. cache-from: type=gha,scope=${{ matrix.target }} cache-to: type=gha,mode=max,scope=${{ matrix.target }} + - name: Resolve exact candidate digest + id: source + env: + BUILD_REQUIRED: ${{ matrix.build_required }} + BUILT_DIGEST: ${{ steps.build.outputs.digest }} + EXISTING_DIGEST: ${{ matrix.existing_digest }} + IMAGE: ghcr.io/srcfl/ftw${{ matrix.image_suffix }}:${{ needs.tag.outputs.version }} + run: | + set -euo pipefail + if [ "${BUILD_REQUIRED}" = "true" ]; then + source_digest="${BUILT_DIGEST}" + else + source_digest="${EXISTING_DIGEST}" + fi + test -n "${source_digest}" + current_digest="$(scripts/inspect-image-digest.sh "${IMAGE}")" + if [ "${current_digest}" != "${source_digest}" ]; then + echo "${IMAGE} resolved to ${current_digest}, expected ${source_digest}." >&2 + exit 1 + fi + echo "digest=${source_digest}" >> "${GITHUB_OUTPUT}" + - name: Login to compatibility GHCR namespace uses: docker/login-action@v4.6.0 with: @@ -140,24 +356,57 @@ jobs: - name: Mirror exact beta manifests to compatibility namespace env: IMAGE_SUFFIX: ${{ matrix.image_suffix }} - SOURCE_DIGEST: ${{ steps.build.outputs.digest }} + SOURCE_DIGEST: ${{ steps.source.outputs.digest }} VERSION: ${{ needs.tag.outputs.version }} run: | set -euo pipefail test -n "${SOURCE_DIGEST}" - for tag in "${VERSION}" beta; do - source="ghcr.io/srcfl/ftw${IMAGE_SUFFIX}:${tag}" - legacy="ghcr.io/frahlg/forty-two-watts${IMAGE_SUFFIX}:${tag}" - docker buildx imagetools create --prefer-index=false --tag "${legacy}" "${source}" - legacy_digest="$(scripts/inspect-image-digest.sh "${legacy}")" - test "${SOURCE_DIGEST}" = "${legacy_digest}" - done + source="ghcr.io/srcfl/ftw${IMAGE_SUFFIX}:${VERSION}" + legacy_repository="frahlg/forty-two-watts${IMAGE_SUFFIX}" + legacy="ghcr.io/${legacy_repository}:${VERSION}" + + token="$(curl -fsSL --get \ + --data-urlencode service=ghcr.io \ + --data-urlencode "scope=repository:${legacy_repository}:pull" \ + https://ghcr.io/token | jq -er '.token')" + http_status="$(curl -sS -o /dev/null -w '%{http_code}' -I \ + -H "Authorization: Bearer ${token}" \ + -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \ + "https://ghcr.io/v2/${legacy_repository}/manifests/${VERSION}")" + case "${http_status}" in + 404) + docker buildx imagetools create --prefer-index=false \ + --tag "${legacy}" "${source}@${SOURCE_DIGEST}" + ;; + 200) + legacy_digest="$(scripts/inspect-image-digest.sh "${legacy}")" + if [ "${legacy_digest}" != "${SOURCE_DIGEST}" ]; then + echo "${legacy} already points to ${legacy_digest}; refusing to overwrite it with ${SOURCE_DIGEST}." >&2 + exit 1 + fi + ;; + *) + echo "Could not determine whether ${legacy} exists (HTTP ${http_status}); refusing to publish." >&2 + exit 1 + ;; + esac + legacy_digest="$(scripts/inspect-image-digest.sh "${legacy}")" + test "${SOURCE_DIGEST}" = "${legacy_digest}" release: name: publish GitHub prerelease runs-on: ubuntu-latest - needs: [tag, docker] + needs: [tag, candidate, docker] + if: ${{ always() && needs.tag.result == 'success' && needs.candidate.result == 'success' && (needs.docker.result == 'success' || needs.docker.result == 'skipped') }} steps: + - name: Checkout beta tag + uses: actions/checkout@v7 + with: + ref: ${{ needs.tag.outputs.version }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + - name: Publish prerelease after images are available env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -165,13 +414,138 @@ jobs: STATE_SCHEMA: ${{ needs.tag.outputs.state_schema }} run: | set -euo pipefail + CORE_DIGEST="$(scripts/inspect-image-digest.sh "ghcr.io/srcfl/ftw:${TAG}")" + UPDATER_DIGEST="$(scripts/inspect-image-digest.sh "ghcr.io/srcfl/ftw-updater:${TAG}")" + test -n "${CORE_DIGEST}" + test -n "${UPDATER_DIGEST}" + STABLE_VERSION="${TAG#v}" + STABLE_VERSION="${STABLE_VERSION%-beta.*}" + jq -n \ + --arg tag "${TAG}" \ + --arg commit "${GITHUB_SHA}" \ + --arg version "${STABLE_VERSION}" \ + --arg core "${CORE_DIGEST}" \ + --arg updater "${UPDATER_DIGEST}" \ + '{schema: 1, tag: $tag, commit: $commit, stable_version: $version, images: {core: {digest: $core}, updater: {digest: $updater}}}' \ + > ftw-image-digests.json + if gh release view "${TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then - echo "GitHub prerelease ${TAG} already exists" + mkdir -p existing + if gh release download "${TAG}" --repo "${GITHUB_REPOSITORY}" \ + --pattern ftw-image-digests.json --dir existing >/dev/null 2>&1; then + cmp ftw-image-digests.json existing/ftw-image-digests.json + echo "GitHub prerelease ${TAG} already records these exact image digests" + exit 0 + fi + else + gh release create "${TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --title "${TAG}" \ + --prerelease \ + --notes "" \ + --generate-notes + fi + gh release upload "${TAG}" ftw-image-digests.json \ + --repo "${GITHUB_REPOSITORY}" + + channel: + name: publish coherent beta aliases + runs-on: ubuntu-latest + needs: [tag, release] + steps: + - name: Checkout beta tag + uses: actions/checkout@v7 + with: + ref: ${{ needs.tag.outputs.version }} + fetch-depth: 0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to canonical GHCR namespace + uses: docker/login-action@v4.6.0 + with: + registry: ghcr.io + username: ${{ vars.SOURCEFUL_GHCR_USER || github.actor }} + password: ${{ secrets.SOURCEFUL_GHCR_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Login to compatibility GHCR namespace + uses: docker/login-action@v4.6.0 + with: + registry: ghcr.io + username: frahlg + password: ${{ secrets.LEGACY_GHCR_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Move aliases only after both exact manifests are recorded + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.tag.outputs.version }} + run: | + set -euo pipefail + git fetch --force --tags origin + newest_beta="" + while IFS= read -r candidate; do + if [[ "${candidate}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$ ]]; then + newest_beta="${candidate}" + break + fi + done < <(git tag --list 'v*-beta.*' --sort=-v:refname) + if [ -z "${newest_beta}" ]; then + echo "No beta tag is available for the moving channel." >&2 + exit 1 + fi + if [ "${TAG}" != "${newest_beta}" ]; then + echo "Not moving :beta aliases backwards from ${newest_beta} to ${TAG}." exit 0 fi - gh release create "${TAG}" \ + + mkdir -p receipt + gh release download "${TAG}" \ --repo "${GITHUB_REPOSITORY}" \ - --title "${TAG}" \ - --prerelease \ - --notes "" \ - --generate-notes + --pattern ftw-image-digests.json \ + --dir receipt + record="receipt/ftw-image-digests.json" + jq -e \ + --arg tag "${TAG}" \ + --arg commit "${GITHUB_SHA}" ' + .schema == 1 + and .tag == $tag + and .commit == $commit + and (.images.core.digest | test("^sha256:[0-9a-f]{64}$")) + and (.images.updater.digest | test("^sha256:[0-9a-f]{64}$")) + ' "${record}" >/dev/null + + core_digest="$(jq -r '.images.core.digest' "${record}")" + updater_digest="$(jq -r '.images.updater.digest' "${record}")" + + # Validate every permanent source before the first moving write. A + # failed check must not leave one component's channel ahead of the + # other solely because validation happened later in the loop. + for item in \ + "core|ghcr.io/srcfl/ftw|ghcr.io/frahlg/forty-two-watts" \ + "updater|ghcr.io/srcfl/ftw-updater|ghcr.io/frahlg/forty-two-watts-updater"; do + key="${item%%|*}" + remainder="${item#*|}" + canonical="${remainder%%|*}" + compatibility="${remainder#*|}" + if [ "${key}" = core ]; then expected="${core_digest}"; else expected="${updater_digest}"; fi + canonical_version="${canonical}:${TAG}" + compatibility_version="${compatibility}:${TAG}" + test "$(scripts/inspect-image-digest.sh "${canonical_version}")" = "${expected}" + test "$(scripts/inspect-image-digest.sh "${compatibility_version}")" = "${expected}" + done + + for item in \ + "${core_digest}|ghcr.io/srcfl/ftw|ghcr.io/frahlg/forty-two-watts" \ + "${updater_digest}|ghcr.io/srcfl/ftw-updater|ghcr.io/frahlg/forty-two-watts-updater"; do + expected="${item%%|*}" + remainder="${item#*|}" + canonical="${remainder%%|*}" + compatibility="${remainder#*|}" + canonical_version="${canonical}:${TAG}" + for target in "${canonical}:beta" "${compatibility}:beta"; do + docker buildx imagetools create --prefer-index=false \ + --tag "${target}" "${canonical_version}@${expected}" + test "$(scripts/inspect-image-digest.sh "${target}")" = "${expected}" + done + done diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index e76f9a1b..f813a34a 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -1,26 +1,24 @@ name: release-assets -# Build + publish the artefacts that accompany a tagged release: +# Publish the artefacts that accompany a tagged release: # - cross-platform main binaries (linux amd64/arm64, windows amd64) -# - docker images for the paired core + updater control plane pushed to GHCR +# - validated beta manifests promoted unchanged for Core + updater # - a versioned os_list.json alias for Raspberry Pi Imager # - Discord release announcement # # Triggered by `release.yml` via `gh workflow run release-assets.yml # --ref vX.Y.Z` immediately after the tag + GitHub Release are created. -# Also listens directly on `push: tags: ['v*']` so a hand-pushed tag -# (e.g. via `git push origin vX.Y.Z` from a maintainer's laptop) does -# the same thing — but in the normal Changesets flow that won't fire -# because the bot-pushed tag from release.yml doesn't cascade workflow -# runs. +# Stable publication is explicit: a hand-pushed tag does not select a beta or +# publish assets. Run release.yml so the chosen validated beta is bound first. # # Splitting these jobs out of release.yml means: # - regular changeset-PR merges to master don't queue + skip a half # dozen asset jobs every time. # - asset builds run on the tag ref directly — no need for the meta # job to thread `outputs.version` from a different workflow run. -# - failures here don't block the next release; a re-run is a clean -# `gh workflow run release-assets.yml --ref vX.Y.Z`. +# - failures here don't block the next release. Before the first promotion +# receipt exists, rerun with `--ref vX.Y.Z -f source_beta=vX.Y.Z-beta.N`. +# Later reruns need only `--ref vX.Y.Z` and reuse the receipt. on: workflow_dispatch: @@ -29,9 +27,10 @@ on: description: "Release tag (vX.Y.Z) — defaults to the tag this workflow is dispatched against" required: false type: string - push: - tags: - - "v*" + source_beta: + description: "Validated beta tag to promote without rebuilding" + required: false + type: string permissions: contents: write # upload release assets + read tag refs @@ -40,7 +39,8 @@ permissions: # Multiple tags shouldn't trample each other. ref-scoped concurrency # group means dispatching v0.102.4 won't cancel an in-flight v0.102.3. concurrency: - group: release-assets-${{ github.ref }} + # Every stable run moves :latest, so serialize across all stable tags. + group: release-assets-stable-latest cancel-in-progress: false jobs: @@ -53,17 +53,24 @@ jobs: outputs: tag: ${{ steps.meta.outputs.tag }} version: ${{ steps.meta.outputs.version }} + source_beta: ${{ steps.meta.outputs.source_beta }} steps: + - name: Checkout release refs + uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Derive tag/version from ref id: meta env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} INPUT_TAG: ${{ github.event.inputs.tag }} + INPUT_BETA: ${{ github.event.inputs.source_beta }} run: | set -euo pipefail # Priority order: # 1. explicit workflow_dispatch `tag` input (operator override). - # 2. github.ref when it's a tag ref (set by `--ref` on dispatch - # OR by `push: tags`). + # 2. github.ref when `--ref` dispatches against a tag. if [ -n "${INPUT_TAG}" ]; then TAG="${INPUT_TAG}" elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then @@ -77,9 +84,89 @@ jobs: exit 1 fi VERSION="${TAG#v}" + STABLE_COMMIT="$(git rev-list -n 1 "${TAG}")" + if [ -z "${STABLE_COMMIT}" ]; then + echo "Cannot resolve stable tag ${TAG}." >&2 + exit 1 + fi + + mkdir -p promotion + PROMOTION_RECORD="promotion/ftw-promotion-receipt.json" + if gh release download "${TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern ftw-promotion-receipt.json \ + --dir promotion >/dev/null 2>&1; then + jq -e \ + --arg tag "${TAG}" \ + --arg commit "${STABLE_COMMIT}" \ + --arg version "${VERSION}" ' + .schema == 1 + and .stable_tag == $tag + and .commit == $commit + and .stable_version == $version + and (.source_beta | type == "string" and length > 0) + and (.images.core.digest | test("^sha256:[0-9a-f]{64}$")) + and (.images.updater.digest | test("^sha256:[0-9a-f]{64}$")) + ' "${PROMOTION_RECORD}" >/dev/null + BETA_TAG="$(jq -r '.source_beta' "${PROMOTION_RECORD}")" + if [ -n "${INPUT_BETA}" ] && [ "${INPUT_BETA}" != "${BETA_TAG}" ]; then + echo "Stable ${TAG} is already bound to ${BETA_TAG}, not requested ${INPUT_BETA}." >&2 + exit 1 + fi + else + if [ -z "${INPUT_BETA}" ]; then + echo "Stable ${TAG} has no promotion receipt; source_beta is required for its first promotion." >&2 + exit 1 + fi + BETA_TAG="${INPUT_BETA}" + fi + + if [[ ! "${BETA_TAG}" =~ ^${TAG}-beta\.[0-9]+$ ]]; then + echo "Source ${BETA_TAG} is not a beta for ${TAG}." >&2 + exit 1 + fi + BETA_COMMIT="$(git rev-list -n 1 "${BETA_TAG}")" + if [ "${BETA_COMMIT}" != "${STABLE_COMMIT}" ]; then + echo "${BETA_TAG} points to ${BETA_COMMIT}, not stable commit ${STABLE_COMMIT}." >&2 + exit 1 + fi + + if [ ! -f "${PROMOTION_RECORD}" ]; then + mkdir -p beta-receipt + gh release download "${BETA_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern ftw-image-digests.json \ + --dir beta-receipt + BETA_RECORD="beta-receipt/ftw-image-digests.json" + jq -e \ + --arg tag "${BETA_TAG}" \ + --arg commit "${STABLE_COMMIT}" \ + --arg version "${VERSION}" ' + .schema == 1 + and .tag == $tag + and .commit == $commit + and .stable_version == $version + and (.images.core.digest | test("^sha256:[0-9a-f]{64}$")) + and (.images.updater.digest | test("^sha256:[0-9a-f]{64}$")) + ' "${BETA_RECORD}" >/dev/null + CORE_DIGEST="$(jq -r '.images.core.digest' "${BETA_RECORD}")" + UPDATER_DIGEST="$(jq -r '.images.updater.digest' "${BETA_RECORD}")" + jq -n \ + --arg stable_tag "${TAG}" \ + --arg commit "${STABLE_COMMIT}" \ + --arg version "${VERSION}" \ + --arg source_beta "${BETA_TAG}" \ + --arg core "${CORE_DIGEST}" \ + --arg updater "${UPDATER_DIGEST}" \ + '{schema: 1, stable_tag: $stable_tag, commit: $commit, stable_version: $version, source_beta: $source_beta, images: {core: {digest: $core}, updater: {digest: $updater}}}' \ + > "${PROMOTION_RECORD}" + gh release upload "${TAG}" "${PROMOTION_RECORD}" \ + --repo "${GITHUB_REPOSITORY}" + fi echo "tag=${TAG}" >> "${GITHUB_OUTPUT}" echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" - echo "Building assets for ${TAG} (version ${VERSION})" + echo "source_beta=${BETA_TAG}" >> "${GITHUB_OUTPUT}" + echo "Publishing assets for ${TAG} from exact candidate ${BETA_TAG}" binaries: name: build + upload release binaries @@ -228,7 +315,7 @@ jobs: --repo "${GITHUB_REPOSITORY}" --clobber docker: - name: docker build + push (${{ matrix.target }}) + name: promote exact beta manifest (${{ matrix.target }}) runs-on: ubuntu-latest needs: meta # Core and updater are a paired control plane. The optimizer has its own @@ -239,28 +326,17 @@ jobs: matrix: include: - target: main - dockerfile: ./Dockerfile + digest_key: core image_suffix: "" - title: FTW - description: Unified Home Energy Management System - target: updater - dockerfile: ./Dockerfile.updater + digest_key: updater image_suffix: "-updater" - title: FTW updater - description: Sidecar that executes docker compose pull + up for in-app updates steps: - name: Checkout tag uses: actions/checkout@v7 with: ref: ${{ needs.meta.outputs.tag }} - # drivers/ is gitignored and fetched from the pin; the image copies it. - - name: Fetch the bundled drivers - run: make drivers - - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -271,40 +347,79 @@ jobs: username: ${{ vars.SOURCEFUL_GHCR_USER || github.actor }} password: ${{ secrets.SOURCEFUL_GHCR_TOKEN || secrets.GITHUB_TOKEN }} - - name: Compute image metadata - id: meta - uses: docker/metadata-action@v6 - with: - images: ghcr.io/srcfl/ftw${{ matrix.image_suffix }} - tags: | - type=raw,value=${{ needs.meta.outputs.tag }} - type=raw,value=${{ needs.meta.outputs.version }} - type=raw,value=latest - type=sha,format=short - labels: | - org.opencontainers.image.title=${{ matrix.title }} - org.opencontainers.image.description=${{ matrix.description }} - org.opencontainers.image.source=https://github.com/srcfl/ftw - org.opencontainers.image.version=${{ needs.meta.outputs.version }} - org.opencontainers.image.licenses=Apache-2.0 + - name: Promote the exact candidate manifest + id: promote + env: + IMAGE_SUFFIX: ${{ matrix.image_suffix }} + SOURCE_TAG: ${{ needs.meta.outputs.source_beta }} + DIGEST_KEY: ${{ matrix.digest_key }} + STABLE_TAG: ${{ needs.meta.outputs.tag }} + STABLE_VERSION: ${{ needs.meta.outputs.version }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + source="ghcr.io/srcfl/ftw${IMAGE_SUFFIX}:${SOURCE_TAG}" + digest_dir="${RUNNER_TEMP}/${{ matrix.target }}-digests" + mkdir -p "${digest_dir}" + gh release download "${STABLE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern ftw-promotion-receipt.json \ + --dir "${digest_dir}" + digest_record="${digest_dir}/ftw-promotion-receipt.json" + commit="$(git rev-parse HEAD)" + jq -e \ + --arg stable_tag "${STABLE_TAG}" \ + --arg source_beta "${SOURCE_TAG}" \ + --arg commit "${commit}" \ + --arg version "${STABLE_VERSION}" \ + --arg key "${DIGEST_KEY}" ' + .schema == 1 + and .stable_tag == $stable_tag + and .source_beta == $source_beta + and .commit == $commit + and .stable_version == $version + and (.images[$key].digest | test("^sha256:[0-9a-f]{64}$")) + ' "${digest_record}" >/dev/null + source_digest="$(jq -r --arg key "${DIGEST_KEY}" '.images[$key].digest' "${digest_record}")" + current_digest="$(scripts/inspect-image-digest.sh "${source}")" + if [ "${current_digest}" != "${source_digest}" ]; then + echo "${source} moved after beta publication: recorded ${source_digest}, current ${current_digest}." >&2 + exit 1 + fi - - name: Build and push - id: build - uses: docker/build-push-action@v7 - with: - context: . - file: ${{ matrix.dockerfile }} - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - build-args: | - VERSION=${{ needs.meta.outputs.tag }} - BUILD_SHA=${{ github.sha }} - # Use the GHA cache backend so subsequent builds reuse layers. - # Per-target scope keeps main + updater from evicting each other. - cache-from: type=gha,scope=${{ matrix.target }} - cache-to: type=gha,mode=max,scope=${{ matrix.target }} + metadata="${RUNNER_TEMP}/${{ matrix.target }}-candidate.json" + docker buildx imagetools inspect "${source}@${source_digest}" \ + --format '{{json .Image}}' > "${metadata}" + python3 - "${metadata}" "${commit}" "${STABLE_VERSION}" "${source}" <<'PY' + import json + import sys + + path, expected_revision, expected_version, image_ref = sys.argv[1:] + with open(path, encoding="utf-8") as source_file: + images = json.load(source_file) + for platform in ("linux/amd64", "linux/arm64"): + image = images.get(platform) + if image is None: + raise SystemExit(f"{image_ref} is missing required platform {platform}") + labels = image.get("config", {}).get("Labels", {}) + revision = labels.get("org.opencontainers.image.revision") + version = labels.get("org.opencontainers.image.version") + if revision != expected_revision or version != expected_version: + raise SystemExit( + f"{image_ref} metadata mismatch for {platform}: " + f"revision={revision!r} version={version!r}" + ) + PY + + short_sha="$(git rev-parse --short=7 HEAD)" + for tag in "${STABLE_TAG}" "${STABLE_VERSION}" latest "sha-${short_sha}"; do + target="ghcr.io/srcfl/ftw${IMAGE_SUFFIX}:${tag}" + docker buildx imagetools create --prefer-index=false \ + --tag "${target}" "${source}@${source_digest}" + target_digest="$(scripts/inspect-image-digest.sh "${target}")" + test "${source_digest}" = "${target_digest}" + done + echo "digest=${source_digest}" >> "${GITHUB_OUTPUT}" # Docker stores one credential per registry host, so two consecutive # ghcr.io logins cannot be active at once. Build and publish once to the @@ -319,14 +434,18 @@ jobs: - name: Mirror exact manifests to compatibility namespace env: - CANONICAL_TAGS: ${{ steps.meta.outputs.tags }} - SOURCE_DIGEST: ${{ steps.build.outputs.digest }} + IMAGE_SUFFIX: ${{ matrix.image_suffix }} + STABLE_TAG: ${{ needs.meta.outputs.tag }} + STABLE_VERSION: ${{ needs.meta.outputs.version }} + SOURCE_DIGEST: ${{ steps.promote.outputs.digest }} run: | set -euo pipefail test -n "${SOURCE_DIGEST}" - for source in ${CANONICAL_TAGS}; do - legacy="${source/ghcr.io\/srcfl\/ftw/ghcr.io\/frahlg\/forty-two-watts}" - docker buildx imagetools create --prefer-index=false --tag "${legacy}" "${source}" + short_sha="$(git rev-parse --short=7 HEAD)" + for tag in "${STABLE_TAG}" "${STABLE_VERSION}" latest "sha-${short_sha}"; do + source="ghcr.io/srcfl/ftw${IMAGE_SUFFIX}:${tag}" + legacy="ghcr.io/frahlg/forty-two-watts${IMAGE_SUFFIX}:${tag}" + docker buildx imagetools create --prefer-index=false --tag "${legacy}" "${source}@${SOURCE_DIGEST}" legacy_digest="$(scripts/inspect-image-digest.sh "${legacy}")" test "${SOURCE_DIGEST}" = "${legacy_digest}" done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bafe7d53..15ffb96c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,8 +22,8 @@ name: release # to true and 422-collides with our own `gh release create`. # - After the Version PR merges, dispatch beta.yml from that exact commit # and validate it on real sites. -# - Manually dispatch this workflow to promote the tested commit to stable. -# Promotion refuses a version without a matching beta tag on that commit. +# - Manually dispatch this workflow with the exact tested beta tag. +# Promotion refuses a beta that does not point to this commit. # # Stable publication is deliberately workflow_dispatch-only. A merge creates # the version candidate; an operator chooses when its beta is ready to promote. @@ -42,6 +42,11 @@ on: - 'scripts/apply-codename.cjs' - '.github/workflows/release.yml' workflow_dispatch: + inputs: + source_beta: + description: "Exact beta tag validated on real sites" + required: true + type: string # changesets/action needs to push the Version PR. The publish step # pushes the tag + creates the GH release + dispatches release-assets. @@ -64,6 +69,7 @@ jobs: outputs: published: ${{ steps.publish.outputs.published }} version: ${{ steps.publish.outputs.version }} + beta_tag: ${{ steps.publish.outputs.beta_tag }} steps: - name: Checkout uses: actions/checkout@v7 @@ -144,6 +150,7 @@ jobs: if: github.event_name == 'workflow_dispatch' && steps.changesets.outputs.hasChangesets == 'false' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INPUT_BETA: ${{ inputs.source_beta }} run: | set -euo pipefail @@ -153,6 +160,12 @@ jobs: TAG_EXISTS=false if git ls-remote --exit-code --tags origin "${TAG}" >/dev/null 2>&1; then TAG_EXISTS=true + git fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}" + STABLE_COMMIT="$(git rev-list -n 1 "${TAG}")" + if [ "${STABLE_COMMIT}" != "${GITHUB_SHA}" ]; then + echo "Existing stable tag ${TAG} points to ${STABLE_COMMIT}, not candidate ${GITHUB_SHA}." >&2 + exit 1 + fi fi RELEASE_EXISTS=false if gh release view "${TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then @@ -174,9 +187,14 @@ jobs: # Stable is a promotion, never the first public channel for a commit. # beta.yml also checks that the prerelease base matches package.json. git fetch --force --tags origin - BETA_TAG="$(git tag --list "${TAG}-beta.*" --sort=-v:refname | head -n 1)" - if [ -z "${BETA_TAG}" ]; then - echo "No ${TAG}-beta.N tag exists. Publish and validate beta first, then rerun release." >&2 + BETA_TAG="${INPUT_BETA}" + if [[ ! "${BETA_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$ ]] || \ + [ "${BETA_TAG%-beta.*}" != "${TAG}" ]; then + echo "source_beta must name the exact tested ${TAG}-beta.N candidate." >&2 + exit 1 + fi + if ! git show-ref --verify --quiet "refs/tags/${BETA_TAG}"; then + echo "Selected beta tag ${BETA_TAG} does not exist." >&2 exit 1 fi BETA_COMMIT="$(git rev-list -n 1 "${BETA_TAG}")" @@ -199,16 +217,44 @@ jobs: exit 1 fi + DIGEST_DIR="${RUNNER_TEMP}/beta-digests" + mkdir -p "${DIGEST_DIR}" + gh release download "${BETA_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern ftw-image-digests.json \ + --dir "${DIGEST_DIR}" + DIGEST_RECORD="${DIGEST_DIR}/ftw-image-digests.json" + jq -e \ + --arg tag "${BETA_TAG}" \ + --arg commit "${GITHUB_SHA}" \ + --arg version "${VERSION}" ' + .schema == 1 + and .tag == $tag + and .commit == $commit + and .stable_version == $version + and (.images.core.digest | test("^sha256:[0-9a-f]{64}$")) + and (.images.updater.digest | test("^sha256:[0-9a-f]{64}$")) + ' "${DIGEST_RECORD}" >/dev/null + CORE_DIGEST="$(jq -r '.images.core.digest' "${DIGEST_RECORD}")" + UPDATER_DIGEST="$(jq -r '.images.updater.digest' "${DIGEST_RECORD}")" + verify_beta_image() { local image_ref="$1" + local expected_digest="$2" local metadata + local current_digest metadata="${RUNNER_TEMP}/$(echo "${image_ref}" | tr '/:' '--').json" - if ! docker buildx imagetools inspect "${image_ref}" \ + current_digest="$(scripts/inspect-image-digest.sh "${image_ref}")" + if [ "${current_digest}" != "${expected_digest}" ]; then + echo "Published beta tag ${image_ref} moved: recorded ${expected_digest}, current ${current_digest}." >&2 + exit 1 + fi + if ! docker buildx imagetools inspect "${image_ref}@${expected_digest}" \ --format '{{json .Image}}' > "${metadata}"; then echo "Published beta image ${image_ref} is unavailable." >&2 exit 1 fi - python3 - "${metadata}" "${GITHUB_SHA}" "${BETA_TAG}" "${image_ref}" <<'PY' + python3 - "${metadata}" "${GITHUB_SHA}" "${VERSION}" "${image_ref}" <<'PY' import json import sys @@ -232,8 +278,8 @@ jobs: PY } - verify_beta_image "ghcr.io/srcfl/ftw:${BETA_TAG}" - verify_beta_image "ghcr.io/srcfl/ftw-updater:${BETA_TAG}" + verify_beta_image "ghcr.io/srcfl/ftw:${BETA_TAG}" "${CORE_DIGEST}" + verify_beta_image "ghcr.io/srcfl/ftw-updater:${BETA_TAG}" "${UPDATER_DIGEST}" echo "Promoting tested ${BETA_TAG} to ${TAG}." # Build codename-annotated release notes. The script extracts @@ -253,11 +299,9 @@ jobs: if [ "${TAG_EXISTS}" = "false" ]; then git tag -a "${TAG}" -m "Release ${TAG}" - # Pushed over origin's persisted GITHUB_TOKEN creds. GITHUB_TOKEN - # pushes don't cascade workflows, so release-assets.yml's - # `push: tags` trigger stays dormant and the manual dispatch below - # remains its single trigger. (This is why checkout must NOT - # persist the CI_TOKEN PAT — see the Checkout step.) + # Push with the persisted GITHUB_TOKEN credentials. The explicit + # dispatch below binds the chosen beta before assets can publish. + # Checkout must not persist the CI_TOKEN PAT; see the Checkout step. git push origin "${TAG}" fi @@ -271,21 +315,22 @@ jobs: { echo "published=true" echo "version=${VERSION}" + echo "beta_tag=${BETA_TAG}" } >> "${GITHUB_OUTPUT}" echo "Published ${TAG}" - name: Dispatch release-assets workflow - # GitHub does NOT fire downstream `push: tags` workflows from - # events created by GITHUB_TOKEN, so the tag push above won't - # auto-trigger release-assets.yml. workflow_dispatch via gh - # CLI does. (u-front uses the identical pattern.) + # release-assets.yml only accepts explicit dispatches, which carry the + # exact validated beta selected above. if: steps.publish.outputs.published == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: v${{ steps.publish.outputs.version }} + BETA_TAG: ${{ steps.publish.outputs.beta_tag }} run: | set -euo pipefail echo "Dispatching release-assets.yml for ${TAG}" gh workflow run release-assets.yml \ --repo "${GITHUB_REPOSITORY}" \ - --ref "${TAG}" + --ref "${TAG}" \ + -f source_beta="${BETA_TAG}" diff --git a/Dockerfile b/Dockerfile index a81b73c7..1813669c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,10 +29,11 @@ COPY go/ ./go/ ARG TARGETOS=linux ARG TARGETARCH ARG VERSION=dev +ARG CANDIDATE_TAG RUN cd go && \ target_arch="${TARGETARCH:-$(go env GOARCH)}" && \ CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${target_arch} \ - go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" \ + go build -trimpath -ldflags="-s -w -X main.Version=${VERSION} -X main.CandidateTag=${CANDIDATE_TAG}" \ -o /out/ftw ./cmd/ftw && \ CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${target_arch} \ go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" \ diff --git a/Makefile b/Makefile index 0ab17cb8..98ed4563 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ # make dev — start sims + main app (hot-reload workflow) # make clean — remove all build artifacts -.PHONY: help test optimizer-install optimizer-test compose-migration-test container-boundary-test build build-arm64 build-amd64 build-windows-amd64 release \ +.PHONY: help test optimizer-install optimizer-test compose-migration-test container-boundary-test release-workflow-test build build-arm64 build-amd64 build-windows-amd64 release \ run-sim dev fmt vet clean e2e ci ci-ui ci-hw-pi docs \ verify verify-all install-hooks driver-repository-validate driver-versions \ drivers drivers-present driver-versions-across-pin @@ -106,6 +106,10 @@ compose-migration-test: container-boundary-test: bash scripts/test-container-boundaries.sh +release-workflow-test: + bash -n scripts/test-exact-image-promotion.sh + bash scripts/test-exact-image-promotion.sh + optimizer/.venv/.installed: optimizer/pyproject.toml $(MAKE) optimizer-install @@ -143,7 +147,7 @@ ci-hw-pi: # verify-all adds cross-compile checks for all release targets, catching # platform-specific syscall/import mistakes before push. -verify: test compose-migration-test container-boundary-test +verify: test compose-migration-test container-boundary-test release-workflow-test cd go && go vet ./... cd go && go build ./... @echo "verify: vet + test + build clean" diff --git a/docker-compose.macos.yml b/docker-compose.macos.yml index fa8af2fd..88bce271 100644 --- a/docker-compose.macos.yml +++ b/docker-compose.macos.yml @@ -53,6 +53,9 @@ services: # In-app self-update feature (version banner + Update/Restart # buttons). Wired to the ftw-updater sidecar below. FTW_SELFUPDATE_ENABLED: "1" + # Selects the exact build-bound beta or stable identity. The updater + # pins this tag in .env after an update. + FTW_IMAGE_TAG: ${FTW_IMAGE_TAG:-} # Optional Bearer token for mutations through public/FQDN hostnames. # Store it in .env so updater-driven recreates retain it. FTW_API_TOKEN: ${FTW_API_TOKEN:-} diff --git a/docker-compose.yml b/docker-compose.yml index 414b4fab..82d3dfd1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,10 @@ services: # sidecar below; native / OS-image deploys leave this unset so the # UI hides the feature entirely. See docs/self-update.md. FTW_SELFUPDATE_ENABLED: "1" + # Beta and stable point at one exact image digest. The image binds both + # allowed release identities; this immutable tag selects one of them. + # The updater pins it in .env after update. + FTW_IMAGE_TAG: ${FTW_IMAGE_TAG:-} # Optional Bearer token for API mutations addressed through a public or # fully-qualified hostname. Keep it in .env so updater-driven recreates # retain it. Local LAN addresses continue to work without a token. diff --git a/docs/self-update.md b/docs/self-update.md index 2a10ff67..78600b05 100644 --- a/docs/self-update.md +++ b/docs/self-update.md @@ -20,13 +20,28 @@ After that PR merges: 1. run [`beta.yml`](../.github/workflows/beta.yml) with `vX.Y.Z-beta.N`; 2. validate that immutable build on real sites; -3. manually dispatch [`release.yml`](../.github/workflows/release.yml) from that same commit; -4. stable promotion verifies that a matching beta tag resolves to the exact +3. manually dispatch [`release.yml`](../.github/workflows/release.yml) from that + same commit and set `source_beta` to the exact candidate tested on sites; +4. stable promotion verifies that the selected beta tag resolves to the exact stable candidate commit; 5. release assets publish `vX.Y.Z` and move the stable aliases. +A hand-pushed stable tag does not publish assets. Use `release.yml`; its +explicit dispatch binds the chosen beta before `release-assets.yml` can run. + Stable therefore cannot be the first public channel for new code. Beta and -stable may have different tags but identify the same source commit. +stable use different tags for the same source commit and the same Core and +updater image digests. The beta prerelease records both index digests; stable +fails if either beta tag moves and promotes only those recorded digests. The +first stable promotion also records the chosen beta and both digests on the +stable release. Asset reruns must reuse that receipt and cannot select a newer +beta from the same commit. The candidate image already contains the stable +product version. Compose passes +its pinned `FTW_IMAGE_TAG` into Core so status, update +checks and fleet reports still show the exact beta tag during site validation. +Core accepts only that build-bound beta tag or the baked stable version; another +environment value cannot invent a release identity. Stable promotion only adds +stable aliases to that validated manifest. ## Immutable update targets diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index f2f4a954..832d8d5d 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -72,6 +72,68 @@ import ( // local runs. var Version = "dev" +// CandidateTag gets injected into release container builds. It binds the +// binary to the one beta identity that produced the image manifest. +var CandidateTag = "" + +// runtimeVersionFromImageTag lets one content-addressed image serve first as +// a beta candidate and then as stable without changing its bytes. Release +// images bake the stable product version into the binary. docker-compose +// supplies the immutable deployed tag. The process accepts only the stable +// identity and the exact beta identity bound into the binary at build time. +func runtimeVersionFromImageTag(baked, candidate, imageTag string) (string, bool) { + imageTag = strings.TrimSpace(imageTag) + if imageTag == "" { + return baked, false + } + base, stable := releaseVersionBase(baked) + if !stable || base != baked { + return baked, false + } + if imageTag == baked { + return baked, true + } + candidateBase, beta := releaseVersionBase(candidate) + if !beta || candidateBase != baked || candidate == candidateBase || imageTag != candidate { + return baked, false + } + return candidate, true +} + +func releaseVersionBase(tag string) (string, bool) { + if !strings.HasPrefix(tag, "v") { + return "", false + } + base := tag + if i := strings.Index(base, "-beta."); i >= 0 { + if i+len("-beta.") == len(base) || !decimalDigits(base[i+len("-beta."):]) { + return "", false + } + base = base[:i] + } else if strings.Contains(base, "-") { + return "", false + } + parts := strings.Split(strings.TrimPrefix(base, "v"), ".") + if len(parts) != 3 { + return "", false + } + for _, part := range parts { + if part == "" || !decimalDigits(part) { + return "", false + } + } + return base, true +} + +func decimalDigits(s string) bool { + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return s != "" +} + // siteIdentityLoad is the machine's own identity, not a user's. // // Bound is set when nova.key has been adopted into a hardware-protected @@ -234,6 +296,11 @@ func adoptGatewayIdentityWith( } func main() { + imageTag := os.Getenv("FTW_IMAGE_TAG") + builtVersion := Version + resolvedVersion, imageTagApplied := runtimeVersionFromImageTag(builtVersion, CandidateTag, imageTag) + Version = resolvedVersion + // Subcommand dispatch — a bare first non-flag argument selects one // of the bootstrap CLIs, e.g. `ftw nova-claim --url=…`. // Everything else is the long-running service. @@ -286,6 +353,9 @@ func main() { stdoutHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}) logger := slog.New(telemetry.NewLogHandler(stdoutHandler, logRing)) slog.SetDefault(logger) + if imageTag != "" && !imageTagApplied { + slog.Warn("ignoring FTW_IMAGE_TAG that does not match a built release identity", "built_version", builtVersion, "built_candidate", CandidateTag, "image_tag", imageTag) + } slog.Info("FTW starting", "version", Version, "config", *configPath) // Route "drivers/.lua" path resolution through the drivers dir diff --git a/go/cmd/ftw/release_version_test.go b/go/cmd/ftw/release_version_test.go new file mode 100644 index 00000000..65208ea4 --- /dev/null +++ b/go/cmd/ftw/release_version_test.go @@ -0,0 +1,33 @@ +package main + +import "testing" + +func TestRuntimeVersionFromImageTag(t *testing.T) { + tests := []struct { + name string + baked string + candidate string + imageTag string + want string + applied bool + }{ + {name: "stable tag", baked: "v2.0.0", candidate: "v2.0.0-beta.4", imageTag: "v2.0.0", want: "v2.0.0", applied: true}, + {name: "exact built beta", baked: "v2.0.0", candidate: "v2.0.0-beta.4", imageTag: "v2.0.0-beta.4", want: "v2.0.0-beta.4", applied: true}, + {name: "invented beta", baked: "v2.0.0", candidate: "v2.0.0-beta.4", imageTag: "v2.0.0-beta.5", want: "v2.0.0"}, + {name: "candidate wrong base", baked: "v2.0.0", candidate: "v2.0.1-beta.1", imageTag: "v2.0.1-beta.1", want: "v2.0.0"}, + {name: "candidate is stable", baked: "v2.0.0", candidate: "v2.0.0", imageTag: "v2.0.0-beta.4", want: "v2.0.0"}, + {name: "latest alias is not identity", baked: "v2.0.0", candidate: "v2.0.0-beta.4", imageTag: "latest", want: "v2.0.0"}, + {name: "wrong base", baked: "v2.0.0", candidate: "v2.0.0-beta.4", imageTag: "v2.0.1-beta.1", want: "v2.0.0"}, + {name: "other prerelease", baked: "v2.0.0", candidate: "v2.0.0-beta.4", imageTag: "v2.0.0-rc.1", want: "v2.0.0"}, + {name: "dev stays dev", baked: "dev", candidate: "v2.0.0-beta.1", imageTag: "v2.0.0-beta.1", want: "dev"}, + {name: "empty", baked: "v2.0.0", candidate: "v2.0.0-beta.4", want: "v2.0.0"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, applied := runtimeVersionFromImageTag(tt.baked, tt.candidate, tt.imageTag) + if got != tt.want || applied != tt.applied { + t.Fatalf("runtimeVersionFromImageTag(%q, %q, %q) = (%q, %v), want (%q, %v)", tt.baked, tt.candidate, tt.imageTag, got, applied, tt.want, tt.applied) + } + }) + } +} diff --git a/scripts/enable-modular-stack.sh b/scripts/enable-modular-stack.sh index f1a6ff4d..71b19770 100644 --- a/scripts/enable-modular-stack.sh +++ b/scripts/enable-modular-stack.sh @@ -64,6 +64,7 @@ trap cleanup EXIT echo 'services:' echo " ${main}:" echo ' environment:' + echo ' FTW_IMAGE_TAG: ${FTW_IMAGE_TAG:-}' echo ' FTW_OPTIMIZER_TRANSPORT: ${FTW_OPTIMIZER_TRANSPORT:-unix}' echo ' FTW_OPTIMIZER_SOCKET: /run/ftw-optimizer/optimizer.sock' echo ' volumes:' diff --git a/scripts/migrate-legacy-compose.sh b/scripts/migrate-legacy-compose.sh index 62dea41b..52d147b1 100755 --- a/scripts/migrate-legacy-compose.sh +++ b/scripts/migrate-legacy-compose.sh @@ -154,6 +154,7 @@ config_check="" expected_data_source="" new_main_id="" optimizer_service_added=false +release_identity_override_needed=false optimizer_container_changed=false modular_override_created="" modular_tmp="" @@ -336,7 +337,22 @@ fi config_check="$(mktemp)" # Scope the mount check to the selected main service. A global grep could be # fooled by an unrelated helper service that happens to mount /app/data. -compose config "$main_service" >"$config_check" +identity_probe="ftw-image-tag-probe" +FTW_IMAGE_TAG="$identity_probe" "${compose_command[@]}" config "$main_service" >"$config_check" +if ! grep -Eq "^[[:space:]]+FTW_IMAGE_TAG:[[:space:]]*[\"']?${identity_probe}[\"']?[[:space:]]*$" "$config_check"; then + if [ "$optimizer_service_added" = false ]; then + for candidate in \ + docker-compose.override.yml \ + docker-compose.override.yaml \ + compose.override.yml \ + compose.override.yaml; do + if [ -e "$candidate" ]; then + die "$candidate does not pass FTW_IMAGE_TAG into $main_service; add that mapping manually before migration" + fi + done + release_identity_override_needed=true + fi +fi data_mount="$(awk ' /^[[:space:]]*-[[:space:]]+type:/ { type = $0 @@ -423,7 +439,7 @@ done cp -p "${compose_files[@]}" "$compose_backup_dir/" log "Compose rollback backup: $compose_backup_dir" -if [ "$optimizer_service_added" = true ]; then +if [ "$optimizer_service_added" = true ] || [ "$release_identity_override_needed" = true ]; then modular_override_created="$install_dir/docker-compose.override.yml" modular_tmp="$modular_override_created.tmp" { @@ -431,27 +447,41 @@ if [ "$optimizer_service_added" = true ]; then echo 'services:' echo " ${main_service}:" echo ' environment:' - echo ' FTW_OPTIMIZER_TRANSPORT: ${FTW_OPTIMIZER_TRANSPORT:-unix}' - echo ' FTW_OPTIMIZER_SOCKET: /run/ftw-optimizer/optimizer.sock' - echo ' volumes:' - echo ' - optimizer-ipc:/run/ftw-optimizer' - echo ' ftw-optimizer:' - echo ' image: ghcr.io/srcfl/ftw-optimizer:${FTW_OPTIMIZER_IMAGE_TAG:-latest}' - echo ' container_name: ftw-optimizer' - echo ' restart: unless-stopped' - echo ' network_mode: none' - echo ' environment:' - echo ' FTW_OPTIMIZER_SOCKET: /run/ftw-optimizer/optimizer.sock' - echo ' volumes:' - echo ' - optimizer-ipc:/run/ftw-optimizer' - echo 'volumes:' - echo ' optimizer-ipc:' + echo ' FTW_IMAGE_TAG: ${FTW_IMAGE_TAG:-}' + if [ "$optimizer_service_added" = true ]; then + echo ' FTW_OPTIMIZER_TRANSPORT: ${FTW_OPTIMIZER_TRANSPORT:-unix}' + echo ' FTW_OPTIMIZER_SOCKET: /run/ftw-optimizer/optimizer.sock' + echo ' volumes:' + echo ' - optimizer-ipc:/run/ftw-optimizer' + echo ' ftw-optimizer:' + echo ' image: ghcr.io/srcfl/ftw-optimizer:${FTW_OPTIMIZER_IMAGE_TAG:-latest}' + echo ' container_name: ftw-optimizer' + echo ' restart: unless-stopped' + echo ' network_mode: none' + echo ' environment:' + echo ' FTW_OPTIMIZER_SOCKET: /run/ftw-optimizer/optimizer.sock' + echo ' volumes:' + echo ' - optimizer-ipc:/run/ftw-optimizer' + echo 'volumes:' + echo ' optimizer-ipc:' + fi } >"$modular_tmp" docker compose -f "$compose_file" -f "$modular_tmp" config >/dev/null mv "$modular_tmp" "$modular_override_created" modular_tmp="" compose_files+=(docker-compose.override.yml) - log "added modular optimizer override: $modular_override_created" + identity_check="$(mktemp)" + FTW_IMAGE_TAG="$identity_probe" "${compose_command[@]}" config "$main_service" >"$identity_check" + if ! grep -Eq "^[[:space:]]+FTW_IMAGE_TAG:[[:space:]]*[\"']?${identity_probe}[\"']?[[:space:]]*$" "$identity_check"; then + rm -f "$identity_check" + die "generated override does not pass FTW_IMAGE_TAG into $main_service" + fi + rm -f "$identity_check" + if [ "$optimizer_service_added" = true ]; then + log "added modular optimizer override: $modular_override_created" + else + log "added release identity override: $modular_override_created" + fi fi capture_service_image() { diff --git a/scripts/test-exact-image-promotion.sh b/scripts/test-exact-image-promotion.sh new file mode 100755 index 00000000..b9d7ab7b --- /dev/null +++ b/scripts/test-exact-image-promotion.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="${FTW_RELEASE_TEST_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +beta="${root}/.github/workflows/beta.yml" +release="${root}/.github/workflows/release.yml" +assets="${root}/.github/workflows/release-assets.yml" +compose="${root}/docker-compose.yml" +compose_macos="${root}/docker-compose.macos.yml" +dockerfile="${root}/Dockerfile" + +grep -Fq 'VERSION=${{ needs.tag.outputs.runtime_version }}' "${beta}" +grep -Fq 'CANDIDATE_TAG=${{ needs.tag.outputs.version }}' "${beta}" +grep -Fq 'org.opencontainers.image.version=${{ needs.tag.outputs.oci_version }}' "${beta}" +grep -Fq 'created: ${{ steps.version.outputs.created }}' "${beta}" +grep -Fq 'group: beta-release-channel' "${beta}" +grep -Fq 'name: guard immutable beta candidate' "${beta}" +grep -Fq 'receipt_found: ${{ steps.guard.outputs.receipt_found }}' "${beta}" +grep -Fq "if: \${{ needs.candidate.outputs.receipt_found != 'true' }}" "${beta}" +grep -Fq 'core_build: ${{ steps.guard.outputs.core_build }}' "${beta}" +grep -Fq 'updater_build: ${{ steps.guard.outputs.updater_build }}' "${beta}" +grep -Fq 'if: ${{ matrix.build_required == '\''true'\'' }}' "${beta}" +grep -Fq 'Reusing verified unfinished component' "${beta}" +grep -Fq 'Could not determine whether ${TAG} has a digest receipt' "${beta}" +grep -Fq 'already points to ${legacy_digest}; refusing to overwrite it with ${SOURCE_DIGEST}.' "${beta}" +grep -Fq 'name: publish coherent beta aliases' "${beta}" +grep -Fq 'Not moving :beta aliases backwards' "${beta}" +grep -Fq '> ftw-image-digests.json' "${beta}" +grep -Fq 'cmp ftw-image-digests.json existing/ftw-image-digests.json' "${beta}" +grep -Fq '"${source}@${SOURCE_DIGEST}"' "${beta}" +grep -Fq -- '-X main.CandidateTag=${CANDIDATE_TAG}' "${dockerfile}" +grep -Fq 'python3 - "${metadata}" "${GITHUB_SHA}" "${VERSION}"' "${release}" +grep -Fq 'STABLE_COMMIT="$(git rev-list -n 1 "${TAG}")"' "${release}" +grep -Fq '[ "${STABLE_COMMIT}" != "${GITHUB_SHA}" ]' "${release}" +grep -Fq 'source_beta:' "${release}" +grep -Fq 'BETA_TAG="${INPUT_BETA}"' "${release}" +grep -Fq -- '--pattern ftw-image-digests.json' "${release}" +grep -Fq 'current_digest="$(scripts/inspect-image-digest.sh "${image_ref}")"' "${release}" +grep -Fq -- '-f source_beta="${BETA_TAG}"' "${release}" +grep -Fq 'name: promote exact beta manifest' "${assets}" +grep -Fq 'group: release-assets-stable-latest' "${assets}" +grep -Fq -- '--pattern ftw-image-digests.json' "${assets}" +grep -Fq -- '--pattern ftw-promotion-receipt.json' "${assets}" +grep -Fq 'source_beta is required for its first promotion.' "${assets}" +grep -Fq 'is already bound to ${BETA_TAG}, not requested ${INPUT_BETA}.' "${assets}" +grep -Fq 'gh release upload "${TAG}" "${PROMOTION_RECORD}"' "${assets}" +grep -Fq 'gh release download "${STABLE_TAG}"' "${assets}" +grep -Fq 'current_digest="$(scripts/inspect-image-digest.sh "${source}")"' "${assets}" +grep -Fq 'test "${source_digest}" = "${target_digest}"' "${assets}" +grep -Fq '"${source}@${SOURCE_DIGEST}"' "${assets}" +grep -Fq 'FTW_IMAGE_TAG: ${FTW_IMAGE_TAG:-}' "${compose}" +grep -Fq 'FTW_IMAGE_TAG: ${FTW_IMAGE_TAG:-}' "${compose_macos}" + +stable_guard="$(grep -n 'STABLE_COMMIT="$(git rev-list -n 1 "${TAG}")"' "${release}" | cut -d: -f1)" +already_done="$(grep -n '# Already-done case:' "${release}" | cut -d: -f1)" +if [ "${stable_guard}" -ge "${already_done}" ]; then + echo "existing stable tag guard runs too late" >&2 + exit 1 +fi + +candidate_guard="$(grep -n '^ candidate:$' "${beta}" | cut -d: -f1)" +beta_docker="$(grep -n '^ docker:$' "${beta}" | cut -d: -f1)" +beta_build="$(grep -n 'uses: docker/build-push-action' "${beta}" | cut -d: -f1)" +if [ "${candidate_guard}" -ge "${beta_docker}" ] || [ "${beta_docker}" -ge "${beta_build}" ]; then + echo "beta immutability guard must complete before any image build" >&2 + exit 1 +fi + +beta_release="$(grep -n '^ release:$' "${beta}" | cut -d: -f1)" +beta_channel="$(grep -n '^ channel:$' "${beta}" | cut -d: -f1)" +if [ "${beta_release}" -ge "${beta_channel}" ]; then + echo "moving beta aliases must wait for the digest receipt" >&2 + exit 1 +fi +if sed -n "${beta_build},${beta_release}p" "${beta}" | grep -Fq ':beta'; then + echo "component builds must not move beta aliases before both images complete" >&2 + exit 1 +fi + +promotion_upload="$(grep -n 'gh release upload "${TAG}" "${PROMOTION_RECORD}"' "${assets}" | cut -d: -f1)" +stable_docker="$(grep -n '^ docker:$' "${assets}" | cut -d: -f1)" +if [ "${promotion_upload}" -ge "${stable_docker}" ]; then + echo "stable promotion receipt must be bound before alias writes" >&2 + exit 1 +fi +if grep -Fq 'git tag --list "${TAG}-beta.*"' "${assets}"; then + echo "stable asset reruns must not auto-select a newer beta" >&2 + exit 1 +fi +if grep -Fq 'git tag --list "${TAG}-beta.*"' "${release}"; then + echo "stable release must use the beta selected by the operator" >&2 + exit 1 +fi + +trigger_start="$(grep -n '^on:$' "${assets}" | cut -d: -f1)" +permissions_start="$(grep -n '^permissions:$' "${assets}" | cut -d: -f1)" +if sed -n "${trigger_start},$((permissions_start - 1))p" "${assets}" | grep -q '^ push:'; then + echo "stable release assets must not run from an unbound tag push" >&2 + exit 1 +fi + +docker_start="$(grep -n '^ docker:$' "${assets}" | cut -d: -f1)" +discord_start="$(grep -n '^ discord:$' "${assets}" | cut -d: -f1)" +if sed -n "${docker_start},$((discord_start - 1))p" "${assets}" | grep -q 'docker/build-push-action'; then + echo "stable docker job still rebuilds an image" >&2 + exit 1 +fi + +echo "exact image promotion workflow checks passed" diff --git a/scripts/test-modular-compose.sh b/scripts/test-modular-compose.sh index 233b209a..82563072 100755 --- a/scripts/test-modular-compose.sh +++ b/scripts/test-modular-compose.sh @@ -62,6 +62,7 @@ override="$TMP/fresh/docker-compose.override.yml" test -f "$override" grep -q '^ ftw-optimizer:' "$override" grep -q 'optimizer-ipc:/run/ftw-optimizer' "$override" +grep -q 'FTW_IMAGE_TAG: ${FTW_IMAGE_TAG:-}' "$override" grep -q 'FTW_OPTIMIZER_TRANSPORT: ${FTW_OPTIMIZER_TRANSPORT:-unix}' "$override" grep -q '^up -d ftw-optimizer ftw$' "$DOCKER_LOG" @@ -264,6 +265,10 @@ case "$command" in esac ;; ftw) + if grep -q 'FTW_IMAGE_TAG:' docker-compose.yml 2>/dev/null || \ + { [ -f docker-compose.override.yml ] && grep -q 'FTW_IMAGE_TAG:' docker-compose.override.yml; }; then + printf 'environment:\n FTW_IMAGE_TAG: %s\n' "${FTW_IMAGE_TAG:-}" + fi printf 'volumes:\n - type: bind\n source: %s\n target: /app/data\n' "$data" ;; esac @@ -318,6 +323,7 @@ FAKE_DATA_DIR="$TMP/migrate/data" \ bash "$ROOT/scripts/migrate-legacy-compose.sh" --dir "$TMP/migrate" grep -q '^ ftw-optimizer:' "$TMP/migrate/docker-compose.override.yml" +grep -q 'FTW_IMAGE_TAG: ${FTW_IMAGE_TAG:-}' "$TMP/migrate/docker-compose.override.yml" grep -q 'FTW_OPTIMIZER_TRANSPORT: ${FTW_OPTIMIZER_TRANSPORT:-unix}' \ "$TMP/migrate/docker-compose.override.yml" test -f "$TMP/migrate/state/ftw" @@ -325,6 +331,37 @@ test -f "$TMP/migrate/state/ftw-updater" test -f "$TMP/migrate/state/ftw-optimizer" test -f "$TMP/migrate"/.ftw-migration-backup-*/previous-images.tsv +# A legacy layout that already has the optimizer still needs the deploy tag +# inside Core. The migration creates a narrow identity-only override instead +# of skipping the mapping with the optimizer service. +mkdir -p "$TMP/existing-optimizer/bin" "$TMP/existing-optimizer/data" "$TMP/existing-optimizer/state" +cp "$TMP/migrate/bin/"* "$TMP/existing-optimizer/bin/" +touch "$TMP/existing-optimizer/data/state.db" +cat >"$TMP/existing-optimizer/docker-compose.yml" <<'YAML' +services: + ftw: + image: example.invalid/old-core:latest + volumes: + - ./data:/app/data + ftw-updater: + image: example.invalid/old-updater:latest + ftw-optimizer: + image: example.invalid/old-optimizer:latest +YAML +PATH="$TMP/existing-optimizer/bin:$PATH" \ +FAKE_STATE_DIR="$TMP/existing-optimizer/state" \ +FAKE_DATA_DIR="$TMP/existing-optimizer/data" \ +bash "$ROOT/scripts/migrate-legacy-compose.sh" --dir "$TMP/existing-optimizer" +grep -q 'FTW_IMAGE_TAG: ${FTW_IMAGE_TAG:-}' \ + "$TMP/existing-optimizer/docker-compose.override.yml" +if grep -q '^ ftw-optimizer:' "$TMP/existing-optimizer/docker-compose.override.yml"; then + echo "existing optimizer migration should create only the Core identity override" >&2 + exit 1 +fi +test -f "$TMP/existing-optimizer/state/ftw" +test -f "$TMP/existing-optimizer/state/ftw-updater" +test -f "$TMP/existing-optimizer/state/ftw-optimizer" + # Generated container names still carry Compose labels. The migration must # reuse their explicit project name instead of creating a parallel default. mkdir -p "$TMP/project/data" "$TMP/project/state" From 19bef91abac60e2a456c01693630106f1f01b4dc Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 16 Aug 2026 19:33:45 +0200 Subject: [PATCH 3/3] fix(updater): preserve release identity on rollback --- go/cmd/ftw-updater/main.go | 70 ++++++++++++++++++++++++++++++--- go/cmd/ftw-updater/main_test.go | 46 +++++++++++++++++++++- 2 files changed, 108 insertions(+), 8 deletions(-) diff --git a/go/cmd/ftw-updater/main.go b/go/cmd/ftw-updater/main.go index 82f7ebea..8cc0c224 100644 --- a/go/cmd/ftw-updater/main.go +++ b/go/cmd/ftw-updater/main.go @@ -113,15 +113,19 @@ type server struct { // pins to the requested version. nil/empty means "inherit only". runner func(ctx context.Context, env []string, args ...string) error // imageID captures the image backing the running service before an update. + // imageRef captures the exact image reference used to create it so an + // automatic rollback can restore a beta's runtime identity as well as its + // bytes. // healthCheck waits for the recreated service to become healthy. Both are // injectable so the rollback path is testable without Docker. imageID func(ctx context.Context, service string) (string, error) + imageRef func(ctx context.Context, service string) (string, error) containerID func(ctx context.Context, service string) (string, error) healthCheck func(ctx context.Context, service string) error // selfReplace brings the updater sidecar to the release Core just moved to. // Injectable so the ordering — only after a verified Core update, never able // to fail one — is testable without Docker. See self_replace.go. - selfReplace func(target string) error + selfReplace func(target string) error chownFile func(string, int, int) error checkSnapshotFile func(context.Context, string, string, string) error stageSnapshotFile func(context.Context, string, string, string, string) error @@ -274,6 +278,7 @@ func main() { } srv.mainServiceName = selectedService srv.imageID = srv.currentServiceImageID + srv.imageRef = srv.currentServiceImageRef srv.containerID = srv.serviceContainerID srv.healthCheck = srv.waitForServiceHealth srv.selfReplace = func(target string) error { @@ -518,11 +523,18 @@ func (s *server) runComponentJob(action, target, component string, startedAt tim // Capture the current immutable image ID before pulling. Docker retains the // old image object after the tag moves, which lets us retag and recreate it // if the new container never becomes healthy. - var previousImageID string + var previousImageID, previousImageTag string if action == "update" && s.imageID != nil { inspectCtx, cancelInspect := context.WithTimeout(context.Background(), 30*time.Second) var err error previousImageID, err = s.imageID(inspectCtx, spec.service) + if err == nil && s.imageRef != nil { + if previousRef, refErr := s.imageRef(inspectCtx, spec.service); refErr == nil { + previousImageTag, _ = imageTagFromReference(previousRef) + } else { + slog.Warn("cannot capture current image tag; rollback will use a synthetic tag", "service", spec.service, "err", refErr) + } + } cancelInspect() if err != nil { s.writeState(State{State: "failed", Action: action, Component: component, Target: target, StartedAt: now, UpdatedAt: time.Now(), Message: "cannot capture current image for rollback: " + err.Error()}) @@ -602,7 +614,7 @@ func (s *server) runComponentJob(action, target, component string, startedAt tim cancelHealth() if healthErr != nil { if action == "update" && previousImageID != "" { - if rollbackErr := s.restorePreviousComponentImage(previousImageID, spec); rollbackErr == nil { + if rollbackErr := s.restorePreviousComponentImageWithTag(previousImageID, previousImageTag, spec); rollbackErr == nil { s.writeState(State{State: "failed", Action: action, Component: component, Target: target, StartedAt: now, UpdatedAt: time.Now(), Message: "new image failed health check; previous image restored: " + healthErr.Error()}) return } else { @@ -708,6 +720,10 @@ func (s *server) restorePreviousImage(imageID string) error { } func (s *server) restorePreviousComponentImage(imageID string, spec componentSpec) error { + return s.restorePreviousComponentImageWithTag(imageID, "", spec) +} + +func (s *server) restorePreviousComponentImageWithTag(imageID, previousTag string, spec componentSpec) error { image, ok, err := serviceImageFromComposeFiles(s.composeFiles(), spec.service) if err != nil { return err @@ -719,7 +735,10 @@ func (s *server) restorePreviousComponentImage(imageID string, spec componentSpe if err != nil { return err } - rollbackTag := fmt.Sprintf("ftw-rollback-%d", time.Now().Unix()) + rollbackTag := previousTag + if rollbackTag == "" { + rollbackTag = fmt.Sprintf("ftw-rollback-%d", time.Now().Unix()) + } rollbackRef := repository + ":" + rollbackTag timeout := 10 * time.Minute if spec.name == "core" { @@ -770,13 +789,19 @@ func (s *server) prepareComponentImagePin(spec componentSpec) (func(), error) { } type imageService struct { - Image string `yaml:"image"` + Image string `yaml:"image"` + Environment map[string]string `yaml:"environment"` } doc := struct { Services map[string]imageService `yaml:"services"` }{ Services: map[string]imageService{ - spec.service: {Image: spec.image + ":${" + spec.tagVariable + ":-latest}"}, + spec.service: { + Image: spec.image + ":${" + spec.tagVariable + ":-latest}", + Environment: map[string]string{ + spec.tagEnv: "${" + spec.tagVariable + ":-latest}", + }, + }, }, } data, err := yaml.Marshal(doc) @@ -1404,6 +1429,39 @@ func (s *server) currentServiceImageID(ctx context.Context, service string) (str return imageID, nil } +func (s *server) currentServiceImageRef(ctx context.Context, service string) (string, error) { + containerID, err := s.serviceContainerID(ctx, service) + if err != nil { + return "", err + } + out, err := dockerOutput(ctx, "inspect", "--format", "{{.Config.Image}}", containerID) + if err != nil { + return "", err + } + imageRef := strings.TrimSpace(out) + if imageRef == "" { + return "", errors.New("running container has no image reference") + } + return imageRef, nil +} + +func imageTagFromReference(imageRef string) (string, bool) { + imageRef = strings.TrimSpace(imageRef) + if imageRef == "" || strings.Contains(imageRef, "@") { + return "", false + } + lastSlash := strings.LastIndexByte(imageRef, '/') + lastColon := strings.LastIndexByte(imageRef, ':') + if lastColon <= lastSlash || lastColon == len(imageRef)-1 { + return "", false + } + tag := imageRef[lastColon+1:] + if !isImmutableImageTag(tag) { + return "", false + } + return tag, true +} + func (s *server) waitForServiceHealth(ctx context.Context, service string) error { containerID, err := s.serviceContainerID(ctx, service) if err != nil { diff --git a/go/cmd/ftw-updater/main_test.go b/go/cmd/ftw-updater/main_test.go index 8ac545c5..0740663a 100644 --- a/go/cmd/ftw-updater/main_test.go +++ b/go/cmd/ftw-updater/main_test.go @@ -15,6 +15,8 @@ import ( "sync" "testing" "time" + + "gopkg.in/yaml.v3" ) // fakeRunner records the compose commands the server attempted to run so @@ -527,6 +529,21 @@ services: if image != canonicalMainImage+":${FTW_IMAGE_TAG:-latest}" { t.Fatalf("effective image = %q", image) } + overrideData, err := os.ReadFile(s.updateOverrideFile) + if err != nil { + t.Fatal(err) + } + var override struct { + Services map[string]struct { + Environment map[string]string `yaml:"environment"` + } `yaml:"services"` + } + if err := yaml.Unmarshal(overrideData, &override); err != nil { + t.Fatal(err) + } + if got := override.Services[legacyMainServiceName].Environment[mainTagEnv]; got != "${FTW_IMAGE_TAG:-latest}" { + t.Fatalf("compatibility override %s = %q", mainTagEnv, got) + } got, err := os.ReadFile(s.composeFile) if err != nil { t.Fatal(err) @@ -867,6 +884,9 @@ func TestUpdateHealthFailureRestoresPreviousImage(t *testing.T) { `) s.mainServiceName = legacyMainServiceName s.imageID = func(context.Context, string) (string, error) { return "sha256:previous", nil } + s.imageRef = func(context.Context, string) (string, error) { + return "ghcr.io/srcfl/ftw:v1.2.2-beta.4", nil + } checks := 0 s.healthCheck = func(_ context.Context, service string) error { if service == optimizerServiceName { @@ -893,12 +913,34 @@ func TestUpdateHealthFailureRestoresPreviousImage(t *testing.T) { if got := strings.Join(calls[2], " "); !strings.Contains(got, "image tag sha256:previous") { t.Fatalf("third call should tag previous image, got %q", got) } - if got := strings.Join(calls[2], " "); !strings.Contains(got, canonicalMainImage+":ftw-rollback-") { - t.Fatalf("previous legacy image should be retagged into canonical repository, got %q", got) + if got := strings.Join(calls[2], " "); !strings.Contains(got, canonicalMainImage+":v1.2.2-beta.4") { + t.Fatalf("previous legacy beta should keep its exact tag, got %q", got) } if got := strings.Join(calls[3], " "); !strings.Contains(got, "ftw-compose-update-") || calls[3][len(calls[3])-1] != legacyMainServiceName { t.Fatalf("rollback must reuse transient pin and legacy service identity, got %q", got) } + if got := runner.envSnapshot()[3]; len(got) != 1 || got[0] != "FTW_IMAGE_TAG=v1.2.2-beta.4" { + t.Fatalf("rollback env = %v", got) + } +} + +func TestImageTagFromReferenceAcceptsOnlyImmutableReleaseTags(t *testing.T) { + for _, tc := range []struct { + ref string + want string + ok bool + }{ + {ref: "ghcr.io/srcfl/ftw:v2.0.0-beta.7", want: "v2.0.0-beta.7", ok: true}, + {ref: "ghcr.io/srcfl/ftw:v2.0.0", want: "v2.0.0", ok: true}, + {ref: "ghcr.io/srcfl/ftw:latest"}, + {ref: "ghcr.io/srcfl/ftw@sha256:deadbeef"}, + {ref: "ghcr.io/srcfl/ftw"}, + } { + got, ok := imageTagFromReference(tc.ref) + if got != tc.want || ok != tc.ok { + t.Errorf("imageTagFromReference(%q) = %q, %v; want %q, %v", tc.ref, got, ok, tc.want, tc.ok) + } + } } func TestRecoverCrashedState(t *testing.T) {