From af26bae4e3fdca14bf7de69473c6978c9ec7129e Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Fri, 28 Aug 2026 00:15:29 +0000 Subject: [PATCH 1/2] ci: publish versioned proxy containers from Maple --- .agents/skills/develop-maple-proxy/SKILL.md | 18 +- .agents/skills/release-maple/SKILL.md | 26 +- .github/workflows/proxy-container.yml | 2 + .github/workflows/proxy-publish.yml | 362 ++++++++++++++++++ .github/workflows/release-gates-tests.yml | 6 + proxy/Dockerfile | 2 + proxy/README.md | 25 +- scripts/ci/plan-proxy-container-publish.sh | 62 +++ .../ci/test-plan-proxy-container-publish.sh | 95 +++++ scripts/ci/test-release-gates.sh | 119 +++++- 10 files changed, 696 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/proxy-publish.yml create mode 100755 scripts/ci/plan-proxy-container-publish.sh create mode 100755 scripts/ci/test-plan-proxy-container-publish.sh diff --git a/.agents/skills/develop-maple-proxy/SKILL.md b/.agents/skills/develop-maple-proxy/SKILL.md index ce0fcca55..42890b9bd 100644 --- a/.agents/skills/develop-maple-proxy/SKILL.md +++ b/.agents/skills/develop-maple-proxy/SKILL.md @@ -88,8 +88,8 @@ bodies as untrusted and potentially sensitive. ## Preserve publishing boundaries -Maple's current GitHub Release workflow builds, checksums, attests, uploads, -and re-verifies four native proxy archives. Maple v3.3.9 proved this integrated +Maple's GitHub Release workflow builds, checksums, attests, uploads, and +re-verifies four native proxy archives. Maple v3.3.9 proved this integrated publication path for macOS arm64, Linux arm64, Linux x86_64, and Windows x86_64. Never create a proxy GitHub tag or Release; a proxy-only binary fix ships through a normal Maple patch release. @@ -103,9 +103,17 @@ cargo package --locked --manifest-path proxy/Cargo.toml If the proxy references a new `opensecret` version, publish that SDK crate first. Do not publish either crate from Maple's application Release workflow. -The current root proxy container workflow builds without pushing; adding or -running a GHCR publisher is a separate production action requiring explicit -repository, version, image namespace, tag, and credential authority. +Root proxy container CI builds without pushing. After a successful stable Maple +Release, `.github/workflows/proxy-publish.yml` independently compares that +release's proxy version with the previous stable Maple Release. Unchanged +versions skip, including the unbackfilled 0.3.3 baseline. A strictly newer, +previously unpublished version automatically publishes Linux AMD64/ARM64 to +`ghcr.io/opensecretcloud/maple-proxy` with exact, minor, major, and `latest` +tags. The workflow is serialized, rejects rollback and stale releases, verifies +the public manifest, and supports manual retry from `master`. Maple's Actions +repository must retain write access to that existing organization-scoped GHCR +package. Treat any namespace, trigger, version policy, or package-access change +as a separate production-authority decision. ## Report diff --git a/.agents/skills/release-maple/SKILL.md b/.agents/skills/release-maple/SKILL.md index 178fae5d9..2c65159a7 100644 --- a/.agents/skills/release-maple/SKILL.md +++ b/.agents/skills/release-maple/SKILL.md @@ -22,8 +22,10 @@ commit, external effect, and authority provided by the user. their checksum manifest. Never create a separate proxy Release or proxy tag; `/releases/latest` must continue to identify the Maple application release. - Maple GitHub Releases do not publish `opensecret` or `maple-proxy` to - crates.io and do not push a GHCR image. Those are separately versioned, - separately authorized production mutations. + crates.io. A successful stable release starts a non-gating GHCR sibling that + publishes only when the proxy version changed from the previous stable Maple + Release; unchanged versions skip. The container remains separately versioned + at `ghcr.io/opensecretcloud/maple-proxy`. - GitHub Release creation does not itself submit the release IPA or AAB to Apple App Store review or Google Play. @@ -165,6 +167,18 @@ gh run list --repo OpenSecretCloud/Maple --workflow 'Promote Pages production' \ --json databaseId,status,conclusion,headSha,createdAt,url ``` +Also inspect the independent proxy-container publisher. It should either prove +the expected exact proxy version and public AMD64/ARM64 manifest or explicitly +skip because the proxy version did not change. Retry it with manual dispatch; +never create a proxy tag or Release and never rerun the core Release to repair +it: + +```bash +gh run list --repo OpenSecretCloud/Maple --workflow 'Publish proxy container' \ + --limit 10 \ + --json databaseId,status,conclusion,headSha,createdAt,url +``` + The updater workflow must publish the verified `latest.json` before reporting the desktop updater control plane current. The Pages workflow advances the machine-owned `pages-production` ref to the exact stable release SHA; its @@ -228,9 +242,11 @@ nix develop --no-update-lock-file .#ci -c \ Confirm the release contains all four stable proxy archives and `maple-proxy-release-final.sha256`, and that their attestations and the published-asset verification job succeeded. Report the embedded proxy version -separately from the Maple application version. Do not report crates.io or GHCR -as updated unless their independent publisher was explicitly authorized and -verified. +separately from the Maple application version. Do not report crates.io as +updated unless its independent manual publisher was explicitly authorized and +verified. Report GHCR as published only after its sibling workflow and anonymous +manifest verification succeed; otherwise report the unchanged-version skip or +failure separately. Verify that the hosted updater serves the same metadata as the GitHub Release: diff --git a/.github/workflows/proxy-container.yml b/.github/workflows/proxy-container.yml index 275f040da..6f752f0f5 100644 --- a/.github/workflows/proxy-container.yml +++ b/.github/workflows/proxy-container.yml @@ -8,6 +8,7 @@ on: branches: [master] paths: - ".github/workflows/proxy-container.yml" + - ".github/workflows/proxy-publish.yml" - ".dockerignore" - "proxy/Dockerfile" - "proxy/Cargo.toml" @@ -19,6 +20,7 @@ on: pull_request: paths: - ".github/workflows/proxy-container.yml" + - ".github/workflows/proxy-publish.yml" - ".dockerignore" - "proxy/Dockerfile" - "proxy/Cargo.toml" diff --git a/.github/workflows/proxy-publish.yml b/.github/workflows/proxy-publish.yml new file mode 100644 index 000000000..03e19386f --- /dev/null +++ b/.github/workflows/proxy-publish.yml @@ -0,0 +1,362 @@ +name: Publish proxy container + +run-name: Publish the current maple-proxy container to GHCR + +on: + workflow_run: + workflows: ["Release"] + types: [completed] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: proxy-publishing + cancel-in-progress: false + +env: + REGISTRY: ghcr.io + IMAGE_NAME: opensecretcloud/maple-proxy + +jobs: + prepare: + name: Validate proxy publication + if: >- + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master') || + (github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'release' && + github.event.workflow_run.path == '.github/workflows/release.yml' && + github.event.workflow_run.head_repository.full_name == github.repository) + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + publish: ${{ steps.plan.outputs.publish }} + release_sha: ${{ steps.plan.outputs.release_sha }} + release_tag: ${{ steps.plan.outputs.release_tag }} + proxy_version: ${{ steps.plan.outputs.proxy_version }} + major: ${{ steps.plan.outputs.major }} + minor: ${{ steps.plan.outputs.minor }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Resolve the publication plan + id: plan + env: + EXPECTED_RELEASE_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || '' }} + EXPECTED_RELEASE_TAG: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || '' }} + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + + fail() { + echo "Proxy container publication failed: $*" >&2 + exit 1 + } + + api() { + gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$@" + } + + proxy_version_at_sha() { + local sha="$1" + local manifest + if ! manifest="$( + api "repos/${REPOSITORY}/contents/proxy/Cargo.toml?ref=${sha}" \ + --jq '.content' | tr -d '\n' | base64 --decode + )"; then + return 0 + fi + awk ' + /^\[package\]$/ { in_package = 1; next } + /^\[/ && in_package { exit } + in_package && /^version = "/ { + value = $0 + sub(/^version = "/, "", value) + sub(/".*$/, "", value) + print value + exit + } + ' <<<"${manifest}" + } + + latest_release="$(api "repos/${REPOSITORY}/releases/latest")" + release_id="$(jq -er 'select(.draft == false and .prerelease == false) | .id' <<<"${latest_release}")" + release_tag="$(jq -er '.tag_name' <<<"${latest_release}")" + [[ "${release_tag}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || \ + fail "latest stable release must use an exact vX.Y.Z tag" + + if [ -n "${EXPECTED_RELEASE_TAG}" ] && [ "${release_tag}" != "${EXPECTED_RELEASE_TAG}" ]; then + echo "${EXPECTED_RELEASE_TAG} is no longer the latest stable release; skipping proxy publication." + echo "publish=false" >>"${GITHUB_OUTPUT}" + exit 0 + fi + + release_sha="$(api "repos/${REPOSITORY}/commits/${release_tag}" --jq '.sha')" + [[ "${release_sha}" =~ ^[0-9a-f]{40}$ ]] || fail "release SHA is invalid" + if [ -n "${EXPECTED_RELEASE_SHA}" ] && [ "${release_sha}" != "${EXPECTED_RELEASE_SHA}" ]; then + fail "release tag no longer resolves to the completed workflow SHA" + fi + + master_status="$(api "repos/${REPOSITORY}/compare/${release_sha}...master" --jq '.status')" + case "${master_status}" in + ahead|identical) ;; + *) fail "release SHA is not reachable from protected master" ;; + esac + + releases="$(api "repos/${REPOSITORY}/releases?per_page=100")" + previous_release="$( + jq -ec --argjson current_id "${release_id}" ' + [ .[] | + select(.draft == false and .prerelease == false and .id != $current_id) + ] | first + ' <<<"${releases}" + )" || fail "no previous stable Maple release is available for comparison" + previous_tag="$(jq -er '.tag_name' <<<"${previous_release}")" + [[ "${previous_tag}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || \ + fail "previous stable release must use an exact vX.Y.Z tag" + previous_sha="$(api "repos/${REPOSITORY}/commits/${previous_tag}" --jq '.sha')" + + proxy_version="$(proxy_version_at_sha "${release_sha}")" + previous_proxy_version="$(proxy_version_at_sha "${previous_sha}")" + [ -n "${proxy_version}" ] || fail "current release has no proxy package version" + + registry_token="$( + curl --fail --silent --show-error \ + "https://${REGISTRY}/token?scope=repository:${IMAGE_NAME}:pull" | jq -er '.token' + )" + curl --fail --silent --show-error \ + --header "Authorization: Bearer ${registry_token}" \ + "https://${REGISTRY}/v2/${IMAGE_NAME}/tags/list?n=10000" \ + >"${RUNNER_TEMP}/proxy-tags.json" + + plan="${RUNNER_TEMP}/proxy-publish-plan" + ./scripts/ci/plan-proxy-container-publish.sh \ + "${proxy_version}" "${previous_proxy_version}" \ + "${RUNNER_TEMP}/proxy-tags.json" >"${plan}" + cat "${plan}" >>"${GITHUB_OUTPUT}" + { + echo "release_sha=${release_sha}" + echo "release_tag=${release_tag}" + } >>"${GITHUB_OUTPUT}" + + # The planner emits only validated shell-safe identifiers and versions. + # shellcheck disable=SC1090 + source "${plan}" + case "${reason}" in + baseline) + detail="${previous_tag} predates the in-tree proxy; ${proxy_version} is the unbackfilled baseline" + ;; + unchanged) + detail="maple-proxy remains ${proxy_version} between ${previous_tag} and ${release_tag}" + ;; + already-published) + detail="maple-proxy ${proxy_version} already exists in GHCR" + ;; + new-version) + detail="maple-proxy advances from ${previous_proxy_version} to ${proxy_version}" + ;; + *) fail "publish planner returned an unknown reason" ;; + esac + { + echo "### Proxy container" + echo + echo "${detail}." + } >>"${GITHUB_STEP_SUMMARY}" + + build: + name: Build proxy container (${{ matrix.platform }}) + needs: prepare + if: needs.prepare.outputs.publish == 'true' + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + artifact: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + artifact: arm64 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ needs.prepare.outputs.release_sha }} + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Build and push platform image by digest + id: image + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 + with: + context: . + file: proxy/Dockerfile + platforms: ${{ matrix.platform }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ needs.prepare.outputs.release_sha }} + org.opencontainers.image.version=${{ needs.prepare.outputs.proxy_version }} + cache-from: type=gha,scope=proxy-publish-${{ matrix.platform }} + cache-to: type=gha,mode=max,scope=proxy-publish-${{ matrix.platform }} + provenance: false + sbom: false + outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + + - name: Export platform digest + env: + DIGEST: ${{ steps.image.outputs.digest }} + run: | + set -euo pipefail + [[ "${DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] + mkdir -p "${RUNNER_TEMP}/digests" + touch "${RUNNER_TEMP}/digests/${DIGEST#sha256:}" + + - name: Upload platform digest + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: proxy-container-digest-${{ matrix.artifact }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + publish: + name: Publish proxy container manifest + needs: + - prepare + - build + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + packages: write + steps: + - name: Download platform digests + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + path: ${{ runner.temp }}/digests + pattern: proxy-container-digest-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Publish and verify the multi-platform manifest + env: + GH_TOKEN: ${{ github.token }} + PROXY_VERSION: ${{ needs.prepare.outputs.proxy_version }} + PROXY_MAJOR: ${{ needs.prepare.outputs.major }} + PROXY_MINOR: ${{ needs.prepare.outputs.minor }} + RELEASE_SHA: ${{ needs.prepare.outputs.release_sha }} + RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }} + working-directory: ${{ runner.temp }}/digests + run: | + set -euo pipefail + + current_release_sha="$( + gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_TAG}" --jq '.sha' + )" + [ "${current_release_sha}" = "${RELEASE_SHA}" ] || { + echo "Release tag changed during proxy publication." >&2 + exit 1 + } + current_release_tag="$( + gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq '.tag_name' + )" + [ "${current_release_tag}" = "${RELEASE_TAG}" ] || { + echo "A newer stable Maple release appeared during proxy publication." >&2 + exit 1 + } + + digest_files=(*) + [ "${#digest_files[@]}" -eq 2 ] || { + echo "Expected exactly two platform digests." >&2 + exit 1 + } + image="${REGISTRY}/${IMAGE_NAME}" + sources=() + for digest in "${digest_files[@]}"; do + [[ "${digest}" =~ ^[0-9a-f]{64}$ ]] + sources+=("${image}@sha256:${digest}") + done + + docker buildx imagetools create \ + --tag "${image}:${PROXY_VERSION}" \ + --tag "${image}:${PROXY_MINOR}" \ + --tag "${image}:${PROXY_MAJOR}" \ + --tag "${image}:latest" \ + "${sources[@]}" + + docker logout "${REGISTRY}" + registry_token="$( + curl --fail --silent --show-error \ + "https://${REGISTRY}/token?scope=repository:${IMAGE_NAME}:pull" | jq -er '.token' + )" + expected_digest="" + for tag in "${PROXY_VERSION}" "${PROXY_MINOR}" "${PROXY_MAJOR}" latest; do + headers="$(mktemp)" + curl --fail --silent --show-error \ + --head \ + --header "Authorization: Bearer ${registry_token}" \ + --header 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \ + --dump-header "${headers}" \ + "https://${REGISTRY}/v2/${IMAGE_NAME}/manifests/${tag}" \ + >/dev/null + digest="$(awk 'tolower($1) == "docker-content-digest:" { gsub("\\r", "", $2); print $2 }' "${headers}")" + [[ "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]] + if [ -z "${expected_digest}" ]; then + expected_digest="${digest}" + fi + [ "${digest}" = "${expected_digest}" ] || { + echo "Published tags do not resolve to one manifest digest." >&2 + exit 1 + } + done + + manifest="$( + curl --fail --silent --show-error \ + --header "Authorization: Bearer ${registry_token}" \ + --header 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \ + "https://${REGISTRY}/v2/${IMAGE_NAME}/manifests/${PROXY_VERSION}" + )" + platforms="$(jq -r '.manifests[].platform | "\(.os)/\(.architecture)"' <<<"${manifest}" | sort)" + [ "${platforms}" = $'linux/amd64\nlinux/arm64' ] || { + echo "Published manifest has unexpected platforms:" >&2 + printf '%s\n' "${platforms}" >&2 + exit 1 + } + + { + echo "### Proxy container" + echo + echo "Published \`${image}:${PROXY_VERSION}\` from Maple \`${RELEASE_TAG}\` (\`${RELEASE_SHA}\`)." + echo "Tags \`${PROXY_MINOR}\`, \`${PROXY_MAJOR}\`, and \`latest\` resolve to \`${expected_digest}\`." + echo "Verified anonymous Linux AMD64 and ARM64 manifest access." + } >>"${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/release-gates-tests.yml b/.github/workflows/release-gates-tests.yml index 79ed41224..3cb99a9eb 100644 --- a/.github/workflows/release-gates-tests.yml +++ b/.github/workflows/release-gates-tests.yml @@ -7,9 +7,12 @@ on: - ".github/workflows/release-gates-tests.yml" - ".github/workflows/pages-production.yml" - ".github/workflows/proxy-rust.yml" + - ".github/workflows/proxy-publish.yml" - ".github/workflows/zapstore-publish.yml" - "scripts/ci/classify-app-release.sh" - "scripts/ci/proxy-release.sh" + - "scripts/ci/plan-proxy-container-publish.sh" + - "scripts/ci/test-plan-proxy-container-publish.sh" - "scripts/ci/test-proxy-release-artifacts.sh" - "scripts/ci/test-release-gates.sh" - "scripts/ci/validate-release-version.sh" @@ -29,9 +32,12 @@ on: - ".github/workflows/release-gates-tests.yml" - ".github/workflows/pages-production.yml" - ".github/workflows/proxy-rust.yml" + - ".github/workflows/proxy-publish.yml" - ".github/workflows/zapstore-publish.yml" - "scripts/ci/classify-app-release.sh" - "scripts/ci/proxy-release.sh" + - "scripts/ci/plan-proxy-container-publish.sh" + - "scripts/ci/test-plan-proxy-container-publish.sh" - "scripts/ci/test-proxy-release-artifacts.sh" - "scripts/ci/test-release-gates.sh" - "scripts/ci/validate-release-version.sh" diff --git a/proxy/Dockerfile b/proxy/Dockerfile index 2f0b7f520..ca1f45c08 100644 --- a/proxy/Dockerfile +++ b/proxy/Dockerfile @@ -20,6 +20,8 @@ RUN cargo build --locked --release --bin maple-proxy # Runtime stage - minimal image for production FROM docker.io/debian:bookworm-slim AS runtime +LABEL org.opencontainers.image.source="https://github.com/OpenSecretCloud/Maple" + # Install runtime dependencies RUN apt-get update && apt-get install -y \ ca-certificates \ diff --git a/proxy/README.md b/proxy/README.md index abb123ae9..380fbb897 100644 --- a/proxy/README.md +++ b/proxy/README.md @@ -21,9 +21,8 @@ Environment (TEE) processing. Maple's current release workflow builds native proxy archives for Linux x86_64, Linux ARM64, Apple Silicon macOS, and Windows x86_64 and attaches them to the -ordinary Maple app Release. No post-integration Maple release has been cut yet, -so the current `releases/latest` entry does not contain these files. Until the -first such release is published, build from source as shown below. +ordinary Maple app Release. Maple v3.3.9 completed the first post-integration +publication and verification of all four archives plus their checksum manifest. After that release, verify the assets are present and use the stable download URLs, for example: @@ -288,12 +287,13 @@ cargo run --locked ## 🐳 Docker Deployment -### Legacy Pre-built Image +### Pre-built Image The currently published GHCR `latest` image is the independently released -standalone proxy 0.3.2. Maple's in-tree source is 0.3.3, and the root container -workflow currently builds without pushing. Build from source for the current -code until an explicitly authorized Maple-owned GHCR publisher is added. +standalone proxy 0.3.2. The first Maple-owned publication deliberately does not +backfill the in-tree 0.3.3 version. After the next proxy version change ships in +a successful stable Maple Release, the release-following publisher updates the +same `ghcr.io/opensecretcloud/maple-proxy` package automatically. To run the legacy image deliberately: @@ -321,7 +321,7 @@ just docker-run ### Production Docker Setup -1. **Option A: Use the legacy 0.3.2 image from GHCR** +1. **Option A: Use the published image from GHCR** ```bash # In your docker-compose.yml, use: image: ghcr.io/opensecretcloud/maple-proxy:latest @@ -415,8 +415,13 @@ environment: The source lives under [`proxy/`](https://github.com/OpenSecretCloud/Maple/tree/master/proxy) in the Maple repository. Root, path-scoped workflows run locked Rust checks, supply-chain policy, and non-publishing AMD64/ARM64 container builds for proxy -changes. Production publishing is intentionally handled separately from these -CI checks. +changes. After every successful stable Maple Release, a separate serialized +publisher compares the checked-in proxy version with the previous stable Maple +Release. It skips unchanged versions; a strictly newer version publishes native +AMD64/ARM64 images and moves the exact, minor, major, and `latest` tags. Manual +dispatch retries the current qualifying release but cannot backfill an unchanged +version. Maple must have Actions write access to the existing organization-scoped +GHCR package. `proxy/Cargo.lock`, `sdk/rust/Cargo.lock`, and `frontend/src-tauri/Cargo.lock` remain separate lockfiles. Runtime dependency diff --git a/scripts/ci/plan-proxy-container-publish.sh b/scripts/ci/plan-proxy-container-publish.sh new file mode 100755 index 000000000..c48be8545 --- /dev/null +++ b/scripts/ci/plan-proxy-container-publish.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +fail() { + echo "Proxy container publish plan failed: $*" >&2 + exit 1 +} + +[ "$#" -eq 3 ] || fail "usage: $0 VERSION PREVIOUS_VERSION TAGS_JSON" + +version="$1" +previous_version="$2" +tags_json="$3" +semver_regex='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' + +[[ "${version}" =~ ${semver_regex} ]] || fail "version must be exact canonical X.Y.Z" +[ -z "${previous_version}" ] || [[ "${previous_version}" =~ ${semver_regex} ]] || \ + fail "previous version must be empty or exact canonical X.Y.Z" +[ -f "${tags_json}" ] || fail "tag inventory does not exist: ${tags_json}" +jq -e '.tags | type == "array" and all(.[]; type == "string")' \ + "${tags_json}" >/dev/null || fail "tag inventory must contain a string array" + +major="${version%%.*}" +minor="${version%.*}" +highest="$( + jq -r --arg regex "${semver_regex}" '.tags[] | select(test($regex))' \ + "${tags_json}" | sort -uV | tail -n 1 +)" + +if [ -z "${previous_version}" ]; then + printf 'publish=false\nreason=baseline\nproxy_version=%s\nmajor=%s\nminor=%s\nregistry_version=%s\n' \ + "${version}" "${major}" "${minor}" "${highest}" + exit 0 +fi + +if [ "${version}" = "${previous_version}" ]; then + printf 'publish=false\nreason=unchanged\nproxy_version=%s\nmajor=%s\nminor=%s\nregistry_version=%s\n' \ + "${version}" "${major}" "${minor}" "${highest}" + exit 0 +fi + +newest_release_version="$( + printf '%s\n%s\n' "${previous_version}" "${version}" | sort -V | tail -n 1 +)" +[ "${newest_release_version}" = "${version}" ] || \ + fail "refusing release rollback from ${previous_version} to ${version}" + +if jq -e --arg version "${version}" '.tags | index($version) != null' \ + "${tags_json}" >/dev/null; then + printf 'publish=false\nreason=already-published\nproxy_version=%s\nmajor=%s\nminor=%s\nregistry_version=%s\n' \ + "${version}" "${major}" "${minor}" "${highest}" + exit 0 +fi + +if [ -n "${highest}" ]; then + newest="$(printf '%s\n%s\n' "${highest}" "${version}" | sort -V | tail -n 1)" + [ "${newest}" = "${version}" ] || \ + fail "refusing to publish ${version} after newer ${highest}" +fi + +printf 'publish=true\nreason=new-version\nproxy_version=%s\nmajor=%s\nminor=%s\nregistry_version=%s\n' \ + "${version}" "${major}" "${minor}" "${highest}" diff --git a/scripts/ci/test-plan-proxy-container-publish.sh b/scripts/ci/test-plan-proxy-container-publish.sh new file mode 100755 index 000000000..ba2cf3dcf --- /dev/null +++ b/scripts/ci/test-plan-proxy-container-publish.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +planner="${script_dir}/plan-proxy-container-publish.sh" +temp_dir="$(mktemp -d)" +trap 'rm -rf "${temp_dir}"' EXIT HUP INT TERM + +passed=0 + +pass() { + passed=$((passed + 1)) + printf 'ok %d - %s\n' "${passed}" "$1" +} + +fail() { + echo "not ok - $*" >&2 + exit 1 +} + +expect_failure() { + local label="$1" + shift + if output="$("$@" 2>&1)"; then + printf '%s\n' "${output}" >&2 + fail "${label}" + fi + pass "${label}" +} + +tags="${temp_dir}/tags.json" +cat >"${tags}" <<'JSON' +{"tags":["latest","master","sha-7b89422","0","0.3","0.3.1","0.3.2"]} +JSON + +expected='publish=true +reason=new-version +proxy_version=0.3.3 +major=0 +minor=0.3 +registry_version=0.3.2' +actual="$("${planner}" 0.3.3 0.3.2 "${tags}")" +[ "${actual}" = "${expected}" ] || fail "new version publish plan did not match" +pass "publishes a strictly newer exact version" + +expected='publish=false +reason=already-published +proxy_version=0.3.2 +major=0 +minor=0.3 +registry_version=0.3.2' +actual="$("${planner}" 0.3.2 0.3.1 "${tags}")" +[ "${actual}" = "${expected}" ] || fail "existing version plan did not match" +pass "treats an existing exact version as idempotent" + +expected='publish=false +reason=unchanged +proxy_version=0.3.3 +major=0 +minor=0.3 +registry_version=0.3.2' +actual="$("${planner}" 0.3.3 0.3.3 "${tags}")" +[ "${actual}" = "${expected}" ] || fail "unchanged release plan did not match" +pass "does not backfill an unchanged release version" + +expected='publish=false +reason=baseline +proxy_version=0.3.3 +major=0 +minor=0.3 +registry_version=0.3.2' +actual="$("${planner}" 0.3.3 '' "${tags}")" +[ "${actual}" = "${expected}" ] || fail "baseline release plan did not match" +pass "does not publish the first in-tree baseline" + +expect_failure "rejects a release version rollback" "${planner}" 0.3.0 0.3.1 "${tags}" +printf '{"tags":["latest","0.3.2"]}\n' >"${tags}" +expect_failure "rejects a registry version rollback" "${planner}" 0.3.1 0.3.0 "${tags}" +expect_failure "rejects a non-canonical version" "${planner}" 00.3.3 0.3.2 "${tags}" + +printf '{"tags":"latest"}\n' >"${tags}" +expect_failure "rejects a malformed registry response" "${planner}" 0.3.3 0.3.2 "${tags}" + +printf '{"tags":[]}\n' >"${tags}" +expected='publish=true +reason=new-version +proxy_version=1.0.0 +major=1 +minor=1.0 +registry_version=' +actual="$("${planner}" 1.0.0 0.9.0 "${tags}")" +[ "${actual}" = "${expected}" ] || fail "empty registry plan did not match" +pass "supports the first exact version in an empty registry" + +printf '1..%d\n' "${passed}" diff --git a/scripts/ci/test-release-gates.sh b/scripts/ci/test-release-gates.sh index 304e6bd23..500c9b835 100755 --- a/scripts/ci/test-release-gates.sh +++ b/scripts/ci/test-release-gates.sh @@ -162,9 +162,11 @@ expect_failure "rejects an unknown argument" \ release_json="${temp_root}/release.json" pages_production_json="${temp_root}/pages-production.json" proxy_rust_json="${temp_root}/proxy-rust.json" +proxy_publish_json="${temp_root}/proxy-publish.json" yq -o=json '.' "${repo_root}/.github/workflows/release.yml" > "${release_json}" yq -o=json '.' "${repo_root}/.github/workflows/pages-production.yml" > "${pages_production_json}" yq -o=json '.' "${repo_root}/.github/workflows/proxy-rust.yml" > "${proxy_rust_json}" +yq -o=json '.' "${repo_root}/.github/workflows/proxy-publish.yml" > "${proxy_publish_json}" if rg -n --glob '*.yml' --glob '*.yaml' \ 'gh[[:space:]]+release[[:space:]]+create|softprops/action-gh-release' \ @@ -173,7 +175,7 @@ if rg -n --glob '*.yml' --glob '*.yaml' \ fi pass "repository workflows preserve one Maple GitHub Release object" -python3 - "${release_json}" "${pages_production_json}" "${proxy_rust_json}" <<'PY' +python3 - "${release_json}" "${pages_production_json}" "${proxy_rust_json}" "${proxy_publish_json}" <<'PY' import json import re import sys @@ -218,6 +220,9 @@ with open(sys.argv[2], encoding="utf-8") as handle: with open(sys.argv[3], encoding="utf-8") as handle: proxy_rust = json.load(handle) +with open(sys.argv[4], encoding="utf-8") as handle: + proxy_container_publish = json.load(handle) + release_jobs = release["jobs"] classifier_id = "classify-app-release" check(classifier_id in release_jobs, "Release workflow must have classify-app-release job") @@ -433,10 +438,122 @@ for required_control in ( ): check(required_control in pages_run, f"Pages production step is missing control: {required_control}") +check( + proxy_container_publish.get("permissions") == {"contents": "read"}, + "Proxy container publisher must default to contents: read", +) +check( + not secret_names(proxy_container_publish), + "Proxy container publisher must use no repository or environment secrets", +) +proxy_publish_on = proxy_container_publish.get("on", {}) +check("workflow_dispatch" in proxy_publish_on, "Proxy container publisher must support manual retry") +proxy_publish_workflow_run = proxy_publish_on.get("workflow_run", {}) +check( + proxy_publish_workflow_run.get("workflows") == ["Release"] + and proxy_publish_workflow_run.get("types") == ["completed"], + "Proxy container publisher must follow completed Release workflows", +) +check( + proxy_container_publish.get("concurrency") + == {"group": "proxy-publishing", "cancel-in-progress": False}, + "Proxy container publication must serialize without cancellation", +) +check( + proxy_container_publish.get("env") + == {"REGISTRY": "ghcr.io", "IMAGE_NAME": "opensecretcloud/maple-proxy"}, + "Proxy container publisher must preserve the existing GHCR package", +) + +container_jobs = proxy_container_publish.get("jobs", {}) +check( + set(container_jobs) == {"prepare", "build", "publish"}, + "Proxy container publisher must separate validation, platform builds, and manifest publication", +) +prepare = container_jobs["prepare"] +prepare_if = str(prepare.get("if", "")) +for required_gate in ( + "workflow_run.conclusion == 'success'", + "workflow_run.event == 'release'", + "workflow_run.path == '.github/workflows/release.yml'", + "workflow_run.head_repository.full_name == github.repository", +): + check(required_gate in prepare_if, f"Proxy container publisher is missing gate: {required_gate}") +prepare_runs = "\n".join(str(step.get("run", "")) for step in prepare.get("steps", [])) +for required_control in ( + "repos/${REPOSITORY}/releases/latest", + "repos/${REPOSITORY}/releases?per_page=100", + "contents/proxy/Cargo.toml?ref=${sha}", + "compare/${release_sha}...master", + "plan-proxy-container-publish.sh", +): + check(required_control in prepare_runs, f"Proxy publication plan is missing control: {required_control}") + +container_build = container_jobs["build"] +check(needs(container_build) == ["prepare"], "Proxy container builds must need the validated plan") +check( + container_build.get("if") == "needs.prepare.outputs.publish == 'true'", + "Proxy container builds must skip unchanged versions", +) +check( + container_build.get("permissions") == {"contents": "read", "packages": "write"}, + "Proxy container builds must have only contents read and packages write", +) +container_matrix = container_build.get("strategy", {}).get("matrix", {}).get("include", []) +check( + {entry.get("platform") for entry in container_matrix} == {"linux/amd64", "linux/arm64"}, + "Proxy container publisher must build native AMD64 and ARM64 images", +) +for step in container_build.get("steps", []): + if str(step.get("uses", "")).startswith("actions/checkout@"): + checkout = step.get("with", {}) + check( + checkout.get("ref") == "${{ needs.prepare.outputs.release_sha }}", + "Proxy container builds must checkout the validated release SHA", + ) + check( + checkout.get("persist-credentials") is False, + "Proxy container checkout must not persist credentials", + ) +build_steps = container_build.get("steps", []) +build_push = [step for step in build_steps if step.get("name") == "Build and push platform image by digest"] +check(len(build_push) == 1, "Proxy container platforms must push exactly once by digest") +build_with = build_push[0].get("with", {}) +check(build_with.get("file") == "proxy/Dockerfile", "Proxy container publisher must use proxy/Dockerfile") +check( + "push-by-digest=true" in str(build_with.get("outputs", "")), + "Proxy container platforms must publish only by digest before the manifest", +) + +container_publish = container_jobs["publish"] +check( + set(needs(container_publish)) == {"prepare", "build"}, + "Proxy manifest publisher must wait for the plan and both platform builds", +) +check( + container_publish.get("permissions") == {"contents": "read", "packages": "write"}, + "Proxy manifest publisher must have only contents read and packages write", +) +publish_runs = "\n".join(str(step.get("run", "")) for step in container_publish.get("steps", [])) +for required_tag in ( + '"${image}:${PROXY_VERSION}"', + '"${image}:${PROXY_MINOR}"', + '"${image}:${PROXY_MAJOR}"', + '"${image}:latest"', +): + check(required_tag in publish_runs, f"Proxy manifest publisher is missing tag {required_tag}") +check( + "linux/amd64\\nlinux/arm64" in publish_runs, + "Proxy manifest publisher must anonymously verify the two public platforms", +) + PY pass "workflow release-gate topology is fail closed with isolated downstream publishers" bash "${script_dir}/test-proxy-release-artifacts.sh" >/dev/null pass "proxy release artifact verifier accepts only the complete native asset set" +bash "${script_dir}/test-plan-proxy-container-publish.sh" >/dev/null +pass "proxy container publish planner accepts only new exact versions" + printf '1..%d\n' "${passed}" From df55e11f9d55bff76e83a06cb798f7240756a11d Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Fri, 28 Aug 2026 00:17:05 +0000 Subject: [PATCH 2/2] test: invoke proxy publish planner through bash --- .../ci/test-plan-proxy-container-publish.sh | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/scripts/ci/test-plan-proxy-container-publish.sh b/scripts/ci/test-plan-proxy-container-publish.sh index ba2cf3dcf..af65e2d86 100755 --- a/scripts/ci/test-plan-proxy-container-publish.sh +++ b/scripts/ci/test-plan-proxy-container-publish.sh @@ -39,7 +39,7 @@ proxy_version=0.3.3 major=0 minor=0.3 registry_version=0.3.2' -actual="$("${planner}" 0.3.3 0.3.2 "${tags}")" +actual="$(bash "${planner}" 0.3.3 0.3.2 "${tags}")" [ "${actual}" = "${expected}" ] || fail "new version publish plan did not match" pass "publishes a strictly newer exact version" @@ -49,7 +49,7 @@ proxy_version=0.3.2 major=0 minor=0.3 registry_version=0.3.2' -actual="$("${planner}" 0.3.2 0.3.1 "${tags}")" +actual="$(bash "${planner}" 0.3.2 0.3.1 "${tags}")" [ "${actual}" = "${expected}" ] || fail "existing version plan did not match" pass "treats an existing exact version as idempotent" @@ -59,7 +59,7 @@ proxy_version=0.3.3 major=0 minor=0.3 registry_version=0.3.2' -actual="$("${planner}" 0.3.3 0.3.3 "${tags}")" +actual="$(bash "${planner}" 0.3.3 0.3.3 "${tags}")" [ "${actual}" = "${expected}" ] || fail "unchanged release plan did not match" pass "does not backfill an unchanged release version" @@ -69,17 +69,17 @@ proxy_version=0.3.3 major=0 minor=0.3 registry_version=0.3.2' -actual="$("${planner}" 0.3.3 '' "${tags}")" +actual="$(bash "${planner}" 0.3.3 '' "${tags}")" [ "${actual}" = "${expected}" ] || fail "baseline release plan did not match" pass "does not publish the first in-tree baseline" -expect_failure "rejects a release version rollback" "${planner}" 0.3.0 0.3.1 "${tags}" +expect_failure "rejects a release version rollback" bash "${planner}" 0.3.0 0.3.1 "${tags}" printf '{"tags":["latest","0.3.2"]}\n' >"${tags}" -expect_failure "rejects a registry version rollback" "${planner}" 0.3.1 0.3.0 "${tags}" -expect_failure "rejects a non-canonical version" "${planner}" 00.3.3 0.3.2 "${tags}" +expect_failure "rejects a registry version rollback" bash "${planner}" 0.3.1 0.3.0 "${tags}" +expect_failure "rejects a non-canonical version" bash "${planner}" 00.3.3 0.3.2 "${tags}" printf '{"tags":"latest"}\n' >"${tags}" -expect_failure "rejects a malformed registry response" "${planner}" 0.3.3 0.3.2 "${tags}" +expect_failure "rejects a malformed registry response" bash "${planner}" 0.3.3 0.3.2 "${tags}" printf '{"tags":[]}\n' >"${tags}" expected='publish=true @@ -88,7 +88,7 @@ proxy_version=1.0.0 major=1 minor=1.0 registry_version=' -actual="$("${planner}" 1.0.0 0.9.0 "${tags}")" +actual="$(bash "${planner}" 1.0.0 0.9.0 "${tags}")" [ "${actual}" = "${expected}" ] || fail "empty registry plan did not match" pass "supports the first exact version in an empty registry"