diff --git a/.downstream/README.md b/.downstream/README.md index 785bea524681..836ac2190675 100644 --- a/.downstream/README.md +++ b/.downstream/README.md @@ -14,8 +14,8 @@ create a named issue with the conflicted paths. After a safe candidate merges, automation creates the immutable `vX.Y.Z-codexrip.1` tag once. The normal Downstream Release workflow builds and attests the linux/amd64 OCI image. A successful release dispatches Production -Deploy with `runtime=preserve`; that run waits at the GitHub `production` -Environment for the configured approval. Authorized delegated maintenance may +Deploy with `release_tag` and `confirmation=DEPLOY`; that run waits at the GitHub +`production` Environment for the configured approval. Authorized delegated maintenance may complete that approval; the release workflow does not approve itself. Sub2API does not store a GitHub token. The admin VersionBadge reads only public diff --git a/.downstream/native-domains.md b/.downstream/native-domains.md index c46c558b8424..60dd5807e1da 100644 --- a/.downstream/native-domains.md +++ b/.downstream/native-domains.md @@ -75,12 +75,12 @@ they never silently enable features. ## Release and rollback boundary The host is delivered as one immutable OCI image with image provenance. Normal -deployment uses the existing fixed-image `deploy-preserve` flow. It does not add +deployment uses the fixed-image `deploy` flow. It does not add backups, canaries, model calls or automatic rollback. -`rollback-preserve` still requires a verified compatible data contract. The -deployer recognizes the old and native prompt/skill directory locations by the -same content hashes; missing or ambiguous trees and changed schema remain errors. +An older tag uses the same deployment form and requires database migrations +compatible with that image. Deployment does not restore dropped schema or data; +the migration boundaries below determine whether an earlier image can start. Migration 264 has no down path. Images at or before v0.2.11-codexrip.5 need `sub2api_plugin_state` and `sub2api_plugin_leases`, which it drops, for their diff --git a/.github/scripts/test_upstream_automation_contract.py b/.github/scripts/test_upstream_automation_contract.py index 03e70555a316..fb01cbca9590 100644 --- a/.github/scripts/test_upstream_automation_contract.py +++ b/.github/scripts/test_upstream_automation_contract.py @@ -50,13 +50,18 @@ def test_safe_merge_tags_once_and_handoff_waits_for_approval(self): self.assertIn("-codexrip.1", self.promoter) self.assertIn("endsWith(github.event.workflow_run.head_branch, '-codexrip.1')", self.handoff) self.assertIn("Manual codexrip releases must remain independent", self.handoff) - self.assertIn("operation=deploy-preserve", self.handoff) + self.assertIn('title="Deploy ${RELEASE_TAG}"', self.handoff) + self.assertIn('-f release_tag="$RELEASE_TAG"', self.handoff) + self.assertIn('-f confirmation=DEPLOY', self.handoff) + self.assertNotIn('operation=', self.handoff) self.assertIn("environment:\n name: production", self.production) - self.assertIn("runtime=preserve", self.production) + self.assertIn('run-name: Deploy ${{ inputs.release_tag }}', self.production) + self.assertIn('remote_command="deploy ${IMAGE_REF}"', self.production) + self.assertNotIn('runtime=', self.production) self.assertNotIn("pending_deployments", self.handoff) self.assertNotIn("pending_deployments", self.promoter) self.assertIn("--match-head-commit", self.promoter) - self.assertIn("operation=deploy-preserve", self.promoter) + self.assertNotIn('operation=', self.promoter) self.assertIn("source_sha", self.publisher) self.assertIn("ref: refs/tags/${{ env.RELEASE_TAG }}", self.publisher) self.assertIn("ref: ${{ needs.verify.outputs.source_sha }}", self.publisher) @@ -246,14 +251,14 @@ def run_command(*args): return merged return "" - for existing in (False, True): + for existing in (None, f"Deploy {self.tag}", f"Deploy {self.tag} (preserve)"): def read_api(path, *args): if path == "pulls/152": return pr if path == "git/refs": return {} if path == "actions/workflows/production-deploy.yml/runs?per_page=100": - runs = [{"display_title": f"Deploy {self.tag} (preserve)", "status": "in_progress", + runs = [{"display_title": existing, "status": "in_progress", "conclusion": None, "html_url": "https://github.com/example/run/18"}] if existing else [] return {"workflow_runs": runs} self.fail(f"unexpected API call: {path}") @@ -274,7 +279,7 @@ def read_api(path, *args): dispatches = [c for c in command.call_args_list if c.args[:3] == ("gh", "workflow", "run")] self.assertEqual(dispatches, [] if existing else [call("gh", "workflow", "run", "production-deploy.yml", "--repo", "HTExplicit/sub2api", "--ref", "main", "-f", - "operation=deploy-preserve", "-f", f"release_tag={self.tag}", "-f", "confirmation=DEPLOY")]) + f"release_tag={self.tag}", "-f", "confirmation=DEPLOY")]) if __name__ == "__main__": diff --git a/.github/scripts/upstream_promote.py b/.github/scripts/upstream_promote.py index 7830216dc65e..ef40a4491737 100644 --- a/.github/scripts/upstream_promote.py +++ b/.github/scripts/upstream_promote.py @@ -183,16 +183,17 @@ def promote(number: int) -> None: else: api("git/refs", "-X", "POST", "-f", f"ref=refs/tags/{tag}", "-f", f"sha={merged}") ensure_release(tag, merged) - title = f"Deploy {tag} (preserve)" + # Reuse deployments already dispatched before the form changed. + titles = {f"Deploy {tag}", f"Deploy {tag} (preserve)"} deployments = api("actions/workflows/production-deploy.yml/runs?per_page=100")["workflow_runs"] - previous = [r for r in deployments if r.get("display_title") == title] + previous = [r for r in deployments if r.get("display_title") in titles] if previous: if previous[0]["status"] == "completed" and previous[0]["conclusion"] != "success": raise RuntimeError("previous production run failed; runtime audit required before retry") print("Existing protected deployment: " + previous[0]["html_url"]) return command("gh", "workflow", "run", "production-deploy.yml", "--repo", REPOSITORY, - "--ref", "main", "-f", "operation=deploy-preserve", "-f", f"release_tag={tag}", "-f", "confirmation=DEPLOY") + "--ref", "main", "-f", f"release_tag={tag}", "-f", "confirmation=DEPLOY") print(f"Protected deployment dispatched: {tag}; production approval remains required.") diff --git a/.github/workflows/downstream-release.yml b/.github/workflows/downstream-release.yml index 6b24c551edad..96c863ee54fd 100644 --- a/.github/workflows/downstream-release.yml +++ b/.github/workflows/downstream-release.yml @@ -122,9 +122,6 @@ jobs: org.opencontainers.image.revision=${{ needs.verify.outputs.source_sha }} org.opencontainers.image.created=${{ env.BUILD_DATE }} org.opencontainers.image.licenses=LGPL-3.0 - io.github.htexplicit.upstream-version=${{ env.UPSTREAM_VERSION }} - io.github.htexplicit.patch=codexrip - io.github.htexplicit.cindy-platform-v1=true build-args: | VERSION=${{ env.VERSION }} COMMIT=${{ needs.verify.outputs.source_sha }} diff --git a/.github/workflows/production-deploy.yml b/.github/workflows/production-deploy.yml index 82f632bdd566..cba41b2242cb 100644 --- a/.github/workflows/production-deploy.yml +++ b/.github/workflows/production-deploy.yml @@ -1,73 +1,17 @@ name: Production Deploy -run-name: >- - ${{ inputs.operation == 'rollback' && format('Rollback to {0}', inputs.release_tag) || inputs.operation == 'rollback-preserve' && format('Rollback to {0} (preserve)', inputs.release_tag) || inputs.operation == 'reconcile-runtime' && format('Reconcile runtime for {0}', inputs.release_tag) || inputs.operation == 'deploy-preserve' && format('Deploy {0} (preserve)', inputs.release_tag) || format('Deploy {0} (explicit)', inputs.release_tag) }} +run-name: Deploy ${{ inputs.release_tag }} on: workflow_dispatch: inputs: - operation: - description: 'Deploy, reconcile an exact current release, or roll back' - required: true - default: deploy - type: choice - options: - - deploy - - deploy-preserve - - reconcile-runtime - - rollback - - rollback-preserve release_tag: - description: 'Target release tag; for reconcile, the exact release currently running' + description: 'Target immutable release tag; older releases require compatible database migrations' required: true type: string - expected_current_release_tag: - description: 'Rollback only: exact release tag currently running in production' - required: false - default: '' - type: string confirmation: - description: 'Type DEPLOY, RECONCILE, or ROLLBACK for the selected operation' + description: 'Type DEPLOY to apply the release' required: true type: string - cindy_health: - description: 'Enable Cindy health tracking and exact budget confirmation' - required: true - default: false - type: boolean - cindy_capability_catalog: - description: 'Enable the verified Cindy model and protocol catalog' - required: true - default: false - type: boolean - cindy_search: - description: 'Enable Cindy Search and its supported protocol fallback' - required: true - default: false - type: boolean - image_studio: - description: 'Enable verified Image Studio capabilities (Cindy-only in this release)' - required: true - default: false - type: boolean - cindy_responses_image_bridge: - description: 'Enable the GPT Image 2 Responses image bridge' - required: true - default: false - type: boolean - interrupt_business: - description: 'Temporarily stop Sub2API after backup to drain active requests before recreation' - required: true - default: false - type: boolean - resource_profile: - description: 'Sub2API memory profile for this deployment' - required: true - default: preserve - type: choice - options: - - preserve - - auto - - high permissions: contents: read @@ -82,13 +26,6 @@ jobs: runs-on: ubuntu-latest outputs: image_ref: ${{ steps.resolve.outputs.image_ref }} - expected_current_image_ref: ${{ steps.resolve.outputs.expected_current_image_ref }} - release_tag: ${{ steps.resolve.outputs.release_tag }} - operation: ${{ steps.resolve.outputs.operation }} - cindy_rollout: ${{ steps.resolve.outputs.cindy_rollout }} - maintenance_spec: ${{ steps.resolve.outputs.maintenance_spec }} - runtime_spec: ${{ steps.resolve.outputs.runtime_spec }} - resource_spec: ${{ steps.resolve.outputs.resource_spec }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: @@ -103,30 +40,11 @@ jobs: id: resolve env: GH_TOKEN: ${{ github.token }} - OPERATION: ${{ inputs.operation }} RELEASE_TAG: ${{ inputs.release_tag }} - EXPECTED_CURRENT_RELEASE_TAG: ${{ inputs.expected_current_release_tag }} CONFIRMATION: ${{ inputs.confirmation }} - CINDY_HEALTH: ${{ inputs.cindy_health }} - CINDY_CAPABILITY_CATALOG: ${{ inputs.cindy_capability_catalog }} - CINDY_SEARCH: ${{ inputs.cindy_search }} - IMAGE_STUDIO: ${{ inputs.image_studio }} - CINDY_RESPONSES_IMAGE_BRIDGE: ${{ inputs.cindy_responses_image_bridge }} - INTERRUPT_BUSINESS: ${{ inputs.interrupt_business }} - RESOURCE_PROFILE: ${{ inputs.resource_profile }} shell: bash run: | set -euo pipefail - runtime_mode=explicit - if [[ "$OPERATION" == deploy-preserve ]]; then - OPERATION=deploy - runtime_mode=preserve - elif [[ "$OPERATION" == rollback-preserve ]]; then - # Keeps the running runtime flags; the host refuses unless the data contract is unchanged. - OPERATION=rollback - runtime_mode=preserve - fi - RESOURCE_PROFILE="${RESOURCE_PROFILE:-preserve}" log() { printf 'RESOLVE|%s\n' "$1" >&2; } validate_release_tag() { @@ -134,7 +52,7 @@ jobs: } resolve_release_image() { - local tag=$1 image_role=$2 tag_commit version image inspect digest image_revision image_platform_v1 release_state release_assets release_body + local tag=$1 tag_commit version image digest image_revision release_state release_body local release_image_ref='' release_source='' release_image_count=0 release_source_count=0 line local image_line_re='^Image: `([^`]+)`$' local source_line_re='^Source: `([0-9a-f]{40})`$' @@ -142,11 +60,6 @@ jobs: release_state=$(gh release view "$tag" --json tagName,isDraft,isPrerelease \ --jq '[.tagName,.isDraft,.isPrerelease] | @tsv') || return 1 [[ "$release_state" == "${tag}"$'\tfalse\tfalse' ]] || return 1 - release_assets=$(gh release view "$tag" --json assets --jq '.assets[].name') || return 1 - if grep -Eq '^(seed-descriptor\.json|bundle-manifest\.json|codexrip-reverse-skill-.*\.zip(\.sha256)?|bootstrap-reverse-skill\.(ps1|py))$' <<<"$release_assets"; then - echo "Release $tag contains a removed Skill-specific asset" >&2 - return 1 - fi log "fetch-tag" git fetch origin "refs/tags/${tag}:refs/tags/${tag}" main || return 1 log "rev-list" @@ -176,96 +89,13 @@ jobs: image_revision=$(docker buildx imagetools inspect "${image}@${digest}" --format '{{ index .Image.Config.Labels "org.opencontainers.image.revision" }}') || { log "revision-inspect-failed"; return 1; } log "revision=$image_revision" [[ "$image_revision" == "$tag_commit" ]] || { log "revision-mismatch"; return 1; } - image_platform_v1=$(docker buildx imagetools inspect "${image}@${digest}" --format '{{ index .Image.Config.Labels "io.github.htexplicit.cindy-platform-v1" }}') || { log "platform-inspect-failed"; return 1; } - log "platform=$image_platform_v1" - case "$image_role" in - platform) - [[ "$image_platform_v1" == true ]] || return 1 - ;; - rollback-target) - [[ "$image_platform_v1" == true || "$image_platform_v1" == false || -z "$image_platform_v1" || "$image_platform_v1" == '' ]] || return 1 - ;; - *) - return 1 - ;; - esac printf '%s@%s\n' "$image" "$digest" } - version_strictly_less() { - local target=$1 current=$2 first - [[ "$target" != "$current" ]] || return 1 - first=$(printf '%s\n%s\n' "$target" "$current" | LC_ALL=C sort -V | head -n 1) - [[ "$first" == "$target" ]] - } - validate_release_tag "$RELEASE_TAG" || exit 1 - case "$OPERATION" in - deploy) - [[ "$CONFIRMATION" == DEPLOY ]] || exit 1 - [[ -z "$EXPECTED_CURRENT_RELEASE_TAG" ]] || exit 1 - ;; - reconcile-runtime) - [[ "$CONFIRMATION" == RECONCILE ]] || exit 1 - [[ -z "$EXPECTED_CURRENT_RELEASE_TAG" ]] || exit 1 - ;; - rollback) - [[ "$CONFIRMATION" == ROLLBACK ]] || exit 1 - validate_release_tag "$EXPECTED_CURRENT_RELEASE_TAG" || exit 1 - version_strictly_less "$RELEASE_TAG" "$EXPECTED_CURRENT_RELEASE_TAG" || exit 1 - ;; - *) - echo 'Operation must be deploy, reconcile-runtime, or rollback' >&2 - exit 1 - ;; - esac - [[ "$runtime_mode" == explicit || "$runtime_mode" == preserve ]] || exit 1 - [[ "$INTERRUPT_BUSINESS" == true || "$INTERRUPT_BUSINESS" == false ]] || exit 1 - [[ "$RESOURCE_PROFILE" == preserve || "$RESOURCE_PROFILE" == auto || "$RESOURCE_PROFILE" == high ]] || exit 1 - if [[ "$runtime_mode" == preserve ]]; then - [[ "$INTERRUPT_BUSINESS" == false ]] || exit 1 - [[ "$OPERATION" == deploy || "$OPERATION" == rollback ]] || exit 1 - [[ "$RESOURCE_PROFILE" == preserve ]] || exit 1 - else - for flag in "$CINDY_HEALTH" "$CINDY_CAPABILITY_CATALOG" "$CINDY_SEARCH" "$IMAGE_STUDIO" "$CINDY_RESPONSES_IMAGE_BRIDGE"; do - [[ "$flag" == true || "$flag" == false ]] || exit 1 - done - if [[ "$OPERATION" == rollback ]]; then - [[ "$INTERRUPT_BUSINESS" == false ]] || exit 1 - fi - fi - - expected_current_image_ref='' - case "$OPERATION" in - deploy|reconcile-runtime) - image_ref=$(resolve_release_image "$RELEASE_TAG" platform) || exit 1 - ;; - rollback) - image_ref=$(resolve_release_image "$RELEASE_TAG" rollback-target) || exit 1 - expected_current_image_ref=$(resolve_release_image "$EXPECTED_CURRENT_RELEASE_TAG" platform) || exit 1 - [[ "$expected_current_image_ref" != "$image_ref" ]] || exit 1 - ;; - esac - + [[ "$CONFIRMATION" == DEPLOY ]] || exit 1 + image_ref=$(resolve_release_image "$RELEASE_TAG") || exit 1 echo "image_ref=${image_ref}" >> "$GITHUB_OUTPUT" - echo "expected_current_image_ref=${expected_current_image_ref}" >> "$GITHUB_OUTPUT" - echo "release_tag=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" - echo "operation=${OPERATION}" >> "$GITHUB_OUTPUT" - if [[ "$runtime_mode" == preserve ]]; then - echo 'runtime_spec=runtime=preserve' >> "$GITHUB_OUTPUT" - echo 'cindy_rollout=' >> "$GITHUB_OUTPUT" - echo 'maintenance_spec=' >> "$GITHUB_OUTPUT" - echo 'resource_spec=resource=preserve' >> "$GITHUB_OUTPUT" - else - echo 'runtime_spec=runtime=explicit' >> "$GITHUB_OUTPUT" - echo "cindy_rollout=cindy=${CINDY_HEALTH},${CINDY_CAPABILITY_CATALOG},${CINDY_SEARCH},${IMAGE_STUDIO},${CINDY_RESPONSES_IMAGE_BRIDGE}" >> "$GITHUB_OUTPUT" - if [[ ("$OPERATION" == deploy || "$OPERATION" == reconcile-runtime) && "$INTERRUPT_BUSINESS" == true ]]; then - echo 'maintenance_spec=maintenance=interrupt' >> "$GITHUB_OUTPUT" - else - echo 'maintenance_spec=' >> "$GITHUB_OUTPUT" - fi - echo "resource_spec=resource=${RESOURCE_PROFILE}" >> "$GITHUB_OUTPUT" - fi deploy: needs: resolve @@ -285,72 +115,18 @@ jobs: printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts chmod 600 ~/.ssh/sub2api_deploy ~/.ssh/known_hosts ssh-keygen -y -f ~/.ssh/sub2api_deploy >/dev/null - - name: Apply immutable release operation + - name: Apply immutable release image env: VPS_HOST: ${{ secrets.VPS_HOST }} VPS_PORT: ${{ secrets.VPS_PORT }} VPS_USER: ${{ secrets.VPS_USER }} - OPERATION: ${{ needs.resolve.outputs.operation }} IMAGE_REF: ${{ needs.resolve.outputs.image_ref }} - EXPECTED_CURRENT_IMAGE_REF: ${{ needs.resolve.outputs.expected_current_image_ref }} - CINDY_ROLLOUT: ${{ needs.resolve.outputs.cindy_rollout }} - MAINTENANCE_SPEC: ${{ needs.resolve.outputs.maintenance_spec }} - RUNTIME_SPEC: ${{ needs.resolve.outputs.runtime_spec }} - RESOURCE_SPEC: ${{ needs.resolve.outputs.resource_spec }} shell: bash run: | set -euo pipefail - RESOURCE_SPEC="${RESOURCE_SPEC:-resource=preserve}" [[ "$VPS_PORT" =~ ^[0-9]+$ ]] || exit 1 [[ "$IMAGE_REF" =~ ^ghcr\.io/htexplicit/sub2api:[0-9]+\.[0-9]+\.[0-9]+-codexrip\.[1-9][0-9]*@sha256:[0-9a-f]{64}$ ]] || exit 1 - [[ "$RUNTIME_SPEC" == runtime=explicit || "$RUNTIME_SPEC" == runtime=preserve ]] || exit 1 - [[ "$RESOURCE_SPEC" =~ ^resource=(preserve|auto|high)$ ]] || exit 1 - if [[ "$RUNTIME_SPEC" == runtime=explicit ]]; then - [[ "$CINDY_ROLLOUT" =~ ^cindy=(true|false),(true|false),(true|false),(true|false),(true|false)$ ]] || exit 1 - [[ -z "$MAINTENANCE_SPEC" || "$MAINTENANCE_SPEC" == maintenance=interrupt ]] || exit 1 - [[ "$OPERATION" == deploy || "$OPERATION" == reconcile-runtime || -z "$MAINTENANCE_SPEC" ]] || exit 1 - else - [[ -z "$CINDY_ROLLOUT" && -z "$MAINTENANCE_SPEC" && "$RESOURCE_SPEC" == resource=preserve ]] || exit 1 - [[ "$OPERATION" == deploy || "$OPERATION" == rollback ]] || exit 1 - fi - case "$OPERATION" in - deploy) - [[ -z "$EXPECTED_CURRENT_IMAGE_REF" ]] || exit 1 - if [[ "$RUNTIME_SPEC" == runtime=preserve ]]; then - remote_command="deploy ${IMAGE_REF} runtime=preserve" - else - remote_command="deploy ${IMAGE_REF} ${CINDY_ROLLOUT}" - if [[ "$RESOURCE_SPEC" != resource=preserve ]]; then - remote_command+=" $RESOURCE_SPEC" - fi - if [[ -n "$MAINTENANCE_SPEC" ]]; then - remote_command+=" $MAINTENANCE_SPEC" - fi - fi - ;; - reconcile-runtime) - [[ -z "$EXPECTED_CURRENT_IMAGE_REF" ]] || exit 1 - [[ "$RUNTIME_SPEC" == runtime=explicit ]] || exit 1 - remote_command="reconcile-runtime ${IMAGE_REF} ${CINDY_ROLLOUT}" - if [[ -n "$MAINTENANCE_SPEC" ]]; then - remote_command+=" $MAINTENANCE_SPEC" - fi - ;; - rollback) - [[ "$EXPECTED_CURRENT_IMAGE_REF" =~ ^ghcr\.io/htexplicit/sub2api:[0-9]+\.[0-9]+\.[0-9]+-codexrip\.[1-9][0-9]*@sha256:[0-9a-f]{64}$ ]] || exit 1 - [[ "$EXPECTED_CURRENT_IMAGE_REF" != "$IMAGE_REF" ]] || exit 1 - [[ "$RESOURCE_SPEC" == resource=preserve ]] || exit 1 - if [[ "$RUNTIME_SPEC" == runtime=preserve ]]; then - remote_command="rollback ${IMAGE_REF} from=${EXPECTED_CURRENT_IMAGE_REF} runtime=preserve" - else - remote_command="rollback ${IMAGE_REF} from=${EXPECTED_CURRENT_IMAGE_REF} ${CINDY_ROLLOUT}" - fi - ;; - *) - echo 'Operation must be deploy, reconcile-runtime, or rollback' >&2 - exit 1 - ;; - esac + remote_command="deploy ${IMAGE_REF}" ssh \ -F /dev/null \ -i ~/.ssh/sub2api_deploy \ diff --git a/.github/workflows/upstream-auto-deploy.yml b/.github/workflows/upstream-auto-deploy.yml index fb0a35e88ace..f05e77adde15 100644 --- a/.github/workflows/upstream-auto-deploy.yml +++ b/.github/workflows/upstream-auto-deploy.yml @@ -62,12 +62,13 @@ jobs: shell: bash run: | set -euo pipefail - title="Deploy ${RELEASE_TAG} (preserve)" + title="Deploy ${RELEASE_TAG}" + # Reuse deployments already dispatched before the form changed. + legacy_title="${title} (preserve)" existing=$(gh run list --repo "$GITHUB_REPOSITORY" --workflow production-deploy.yml --event workflow_dispatch --limit 50 \ - --json displayTitle --jq "[.[] | select(.displayTitle == \"${title}\")] | length") + --json displayTitle --jq "[.[] | select(.displayTitle == \"${title}\" or .displayTitle == \"${legacy_title}\")] | length") [[ "$existing" == 0 ]] || exit 0 gh workflow run production-deploy.yml --repo "$GITHUB_REPOSITORY" --ref main \ - -f operation=deploy-preserve \ -f release_tag="$RELEASE_TAG" \ -f confirmation=DEPLOY diff --git a/DOWNSTREAM.md b/DOWNSTREAM.md index de3175c88c13..654e2a8e822d 100644 --- a/DOWNSTREAM.md +++ b/DOWNSTREAM.md @@ -62,8 +62,9 @@ The three `238_*` migrations retain separate filenames and checksums. - Downstream Release authenticates to GHCR, verifies source/build materials and publishes the native host image digest and provenance. It reuses PR validation. - Production Deploy resolves the fixed digest through the existing restricted SSH - updater. Ordinary updates use `operation=deploy-preserve`, preserving runtime - settings and resources, naturally draining requests, and rebuilding only Sub2API. + updater. Its only inputs are `release_tag` and `confirmation=DEPLOY`; the host + retains runtime settings and resources, naturally drains requests, and rebuilds + only Sub2API. An older tag uses the same form and requires compatible migrations. - Ordinary updates do not run business backups, canaries, long observation or automatic rollback. A failure preserves the runtime state for diagnosis. - SSH identity checks, fixed image validation, mutual exclusion and command error diff --git a/deploy/README.md b/deploy/README.md index 1d9560709fc3..068ed8c1446b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -174,111 +174,31 @@ database recovery period is not treated as a permanent process failure. - `schema_migrations` tracks applied migrations (filename + checksum). - Migrations are forward-only; rollback requires a DB backup restore or a manual compensating SQL script. -### Image Studio default and the retained rollout tuple +### Production deployment -`GATEWAY_IMAGE_STUDIO_ENABLED` is the deploy-time default of the Image Studio -switch until an administrator saves the image tool setting; unset or `false` -keeps Image Studio off. Cindy accounts are ordinary OpenAI API-key accounts and -the backend no longer reads any `GATEWAY_CINDY_*` variable. The production -workflow below still carries its historical `cindy=` tuple and the Cindy -platform-v1 label check; the backend ignores those values. - -The protected production workflow exposes the five values as typed boolean -inputs. The platform and jobs phase is dispatched with: - -```bash -gh workflow run production-deploy.yml \ - --repo HTExplicit/sub2api \ - --ref main \ - -f operation=deploy \ - -f release_tag=vX.Y.Z-codexrip.N \ - -f confirmation=DEPLOY \ - -f cindy_health=false \ - -f cindy_capability_catalog=false \ - -f cindy_search=false \ - -f image_studio=false \ - -f cindy_responses_image_bridge=false -``` - -The workflow resolves the release to an immutable digest, requires the Release -body to record that exact image and source commit, and requires the immutable -image's OCI revision and Cindy platform-v1 capability label to match the release. -It sends only `deploy cindy=,,,,` -to the restricted host command. The host persists the tuple in -`/opt/sub2api/docker-compose.cindy-rollout.yml`; a tuple-only change for the -same digest recreates only `sub2api`, with the prior override included in the -checksum-verified rollback set. -During the guard-first migration window, the host accepts only the old workflow's -three-value `cindy=,,` tuple and maps it to the canonical -five-value tuple with Search and Responses-image disabled. -This workflow always emits the explicit five-value tuple. - -If an out-of-band maintenance action recreated `sub2api` from only the base -Compose file while the canonical rollout override remained valid on disk, use -the dedicated reconciliation operation with the exact Release currently -running: +Use the protected production workflow with the immutable release tag and the +exact `DEPLOY` confirmation: ```bash gh workflow run production-deploy.yml \ --repo HTExplicit/sub2api \ --ref main \ - -f operation=reconcile-runtime \ -f release_tag=vX.Y.Z-codexrip.N \ - -f confirmation=RECONCILE \ - -f cindy_health=true \ - -f cindy_capability_catalog=true \ - -f cindy_search=true \ - -f image_studio=false \ - -f cindy_responses_image_bridge=false \ - -f interrupt_business=false -``` - -The host proves that the requested immutable image is already running, the -active container came from base-only Compose, the running tuple matches that -base configuration, and the on-disk canonical override matches the requested -target. It then takes the normal root-only backup and non-target container -snapshot, recreates only `sub2api`, and observes the reconciled runtime for 300 -seconds. Any failure restores the prior base-only runtime; this operation does -not pull or change an image. - -For a controlled stop before recreation, set `-f interrupt_business=true`. The -host appends only `maintenance=interrupt`, stops `sub2api` after the root-only -backup, waits for three stable zero samples of loopback connections and -Redis-backed active leases (up to the fixed 900 second lease grace window), and -then recreates only `sub2api`. A timeout or any post-recreate gate failure -restores the verified base-only runtime and returns a failure status. - -The reconcile path records a fixed same-image native-canary skip marker and does -not create a temporary acceptance credential; all local health, tuple, image, -snapshot, and 300-second observation gates still run. - -An image downgrade uses the same protected `production` Environment and an -explicit expected-current release rather than the deploy path: - -```bash -gh workflow run production-deploy.yml \ - --repo HTExplicit/sub2api \ - --ref main \ - -f operation=rollback \ - -f release_tag=v0.1.177-codexrip.6 \ - -f expected_current_release_tag=v0.1.177-codexrip.7 \ - -f confirmation=ROLLBACK \ - -f cindy_health=true \ - -f cindy_capability_catalog=false \ - -f cindy_search=false \ - -f image_studio=false \ - -f cindy_responses_image_bridge=false + -f confirmation=DEPLOY ``` -The resolver requires both Releases, their recorded immutable references, and -their OCI source revisions to match valid `main` ancestors, and requires -`CURRENT` to be strictly newer than `TARGET`. The forced command is exactly -`rollback from= cindy=...`; the -host rejects it before pulling or mutating state unless the running image is -byte-for-byte equal to `from=`. Ordinary deploys remain unable to downgrade. - -The backend reads only `GATEWAY_IMAGE_STUDIO_ENABLED`; the former -`GATEWAY_CINDY_IMAGE_STUDIO_ENABLED` fallback was removed. +The resolver requires a published stable Release, a release tag on `main`, and +one immutable image reference and source commit in the Release body. The fixed +image's standard OCI revision must match that commit. It sends exactly +`deploy ` to the restricted host command, which naturally drains +requests, changes the image, and recreates only `sub2api`. Runtime settings and +resources remain as configured; container/public health confirms availability, +not actual model functionality. + +An older release uses the same form. Its database migrations must be compatible +with the current data: the workflow does not restore dropped schema or deleted +data and does not automatically roll back a failed deployment. See the migration +boundaries in [native-domains](../.downstream/native-domains.md). **Verify `users.allowed_groups` → `user_allowed_groups` backfill** diff --git a/deploy/tests/production-deploy-workflow-test.sh b/deploy/tests/production-deploy-workflow-test.sh index fef2f40617a8..4db1923926a8 100644 --- a/deploy/tests/production-deploy-workflow-test.sh +++ b/deploy/tests/production-deploy-workflow-test.sh @@ -11,23 +11,29 @@ fail() { exit 1 } -grep -Fq 'packages: read' "$WORKFLOW" || - fail 'production resolve must request GHCR package read permission' +# Authentication, immutable source binding, and the protected deploy job remain. +grep -Fq 'packages: read' "$WORKFLOW" || fail 'resolve must request GHCR read permission' grep -Fq 'docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9' "$WORKFLOW" || - fail 'production resolve must authenticate to GHCR before imagetools inspect' -grep -Fq 'digest="${release_image_ref#${image}@}"' "$WORKFLOW" || - fail 'production resolution must take the immutable digest from the Release body' -grep -Fq 'release_body=$(gh release view "$tag" --json body --jq .body)' "$WORKFLOW" || - fail 'production resolution must read the immutable image reference recorded in the Release body' -grep -Fq '[[ "$release_image_ref" == "${image}@${digest}" ]]' "$WORKFLOW" || - fail 'production resolution must bind the Release digest to the image tag' -grep -Fq 'image_revision=$(docker buildx imagetools inspect "${image}@${digest}" --format' "$WORKFLOW" || - fail 'production resolution must inspect source metadata from the immutable image digest' -grep -Fq '[[ "$image_revision" == "$tag_commit" ]]' "$WORKFLOW" || - fail 'production resolution must bind the immutable image revision to the release tag commit' -if grep -Fq '{print $2; exit}' "$WORKFLOW"; then - fail 'production digest resolution closes the buildx pipe before EOF' + fail 'resolve must authenticate to GHCR' +grep -Fq 'run-name: Deploy ${{ inputs.release_tag }}' "$WORKFLOW" || + fail 'run title must expose the release tag for the status UI' +grep -Fq ' name: production' "$WORKFLOW" || fail 'deployment must use the production environment' +[[ "$(grep -c '^ [a-z_]*:$' "$WORKFLOW")" -eq 2 ]] || fail 'workflow must have only two inputs' +for input in release_tag confirmation; do + grep -Fq " ${input}:" "$WORKFLOW" || fail "missing input: $input" +done +if grep -Eq 'expected_current|runtime=|resource=|CINDY_|IMAGE_STUDIO|INTERRUPT_BUSINESS|rollback|reconcile-runtime|io.github.htexplicit' "$WORKFLOW"; then + fail 'workflow still contains a retired deployment interface or image label gate' +fi +if grep -Fq 'io.github.htexplicit.' "$DOWNSTREAM_RELEASE_WORKFLOW"; then + fail 'release still emits retired fork image labels' fi +for label in source version revision created licenses; do + grep -Fq "org.opencontainers.image.${label}=" "$DOWNSTREAM_RELEASE_WORKFLOW" || + fail "standard OCI label missing: $label" +done +grep -Fq "!contains(github.ref_name, '-codexrip.')" "$GENERIC_RELEASE_WORKFLOW" || + fail 'generic Release must skip downstream codexrip tags' tmpdir=$(mktemp -d) trap 'rm -rf "$tmpdir"' EXIT @@ -38,28 +44,45 @@ awk ' capture && /^ deploy:$/ { exit } capture { sub(/^ /, ""); print } ' "$WORKFLOW" >"$resolve_script" -[[ -s "$resolve_script" ]] || fail 'could not extract the resolve shell step' +[[ -s "$resolve_script" ]] || fail 'could not extract resolve step' apply_script="$tmpdir/apply.sh" awk ' - /^ - name: Apply immutable release operation$/ { found_apply = 1; next } + /^ - name: Apply immutable release image$/ { found_apply = 1; next } found_apply && /^ run: \|$/ { capture = 1; next } capture && /^ - name:/ { exit } capture { sub(/^ /, ""); print } ' "$WORKFLOW" >"$apply_script" -[[ -s "$apply_script" ]] || fail 'could not extract the immutable release operation shell step' +[[ -s "$apply_script" ]] || fail 'could not extract deploy step' +bash -n "$resolve_script" +bash -n "$apply_script" mkdir -p "$tmpdir/bin" cat >"$tmpdir/bin/gh" <<'EOF' #!/usr/bin/env bash set -euo pipefail -if [[ " $* " == *' --json assets '* ]]; then - printf '%s\n' 'seed-descriptor.json' - for ((i = 0; i < 10000; i++)); do - printf 'ordinary-release-asset-%05d-%064d.bin\n' "$i" "$i" - done -elif [[ " $* " == *' --json tagName,isDraft,isPrerelease '* ]]; then - printf '%s\tfalse\tfalse\n' "$3" +tag=$3 +case "$tag" in + v0.1.177-codexrip.6) + digest=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + source=1111111111111111111111111111111111111111 + ;; + v0.1.177-codexrip.7) + digest=sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + source=2222222222222222222222222222222222222222 + ;; + *) exit 3 ;; +esac +if [[ " $* " == *' --json tagName,isDraft,isPrerelease '* ]]; then + printf '%s\t%s\t%s\n' "$tag" "${MOCK_RELEASE_DRAFT:-false}" "${MOCK_RELEASE_PRERELEASE:-false}" +elif [[ " $* " == *' --json body '* ]]; then + printf 'Image: `%s`\nSource: `%s`\n' \ + "${MOCK_BODY_IMAGE:-ghcr.io/htexplicit/sub2api:${tag#v}@${MOCK_BODY_DIGEST:-$digest}}" \ + "${MOCK_BODY_SOURCE:-$source}" + if [[ "${MOCK_DUPLICATE_IMAGE:-false}" == true ]]; then + printf 'Image: `ghcr.io/htexplicit/sub2api:%s@%s`\n' "${tag#v}" "$digest" + fi else + # No asset inventory or retired Skill gate belongs to deployment resolution. exit 2 fi EOF @@ -67,13 +90,13 @@ cat >"$tmpdir/bin/git" <<'EOF' #!/usr/bin/env bash set -euo pipefail case "$1" in - fetch|merge-base) exit 0 ;; + fetch) exit 0 ;; + merge-base) [[ "${MOCK_NON_ANCESTOR:-false}" == false ]] ;; rev-list) tag=${!#} case "$tag" in v0.1.177-codexrip.6) printf '%s\n' 1111111111111111111111111111111111111111 ;; v0.1.177-codexrip.7) printf '%s\n' 2222222222222222222222222222222222222222 ;; - v0.1.177-codexrip.8) printf '%s\n' 3333333333333333333333333333333333333333 ;; *) exit 3 ;; esac ;; @@ -83,489 +106,125 @@ EOF cat >"$tmpdir/bin/docker" <<'EOF' #!/usr/bin/env bash set -euo pipefail -printf 'Name: image\nDigest: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n' -EOF -chmod +x "$tmpdir/bin/gh" "$tmpdir/bin/git" "$tmpdir/bin/docker" - -set +e -PATH="$tmpdir/bin:$PATH" \ -OPERATION=deploy \ -RELEASE_TAG=v0.1.177-codexrip.7 \ -EXPECTED_CURRENT_RELEASE_TAG= \ -CONFIRMATION=DEPLOY \ -CINDY_HEALTH=true \ -CINDY_CAPABILITY_CATALOG=false \ - CINDY_SEARCH=false \ - IMAGE_STUDIO=false \ - CINDY_RESPONSES_IMAGE_BRIDGE=false \ - INTERRUPT_BUSINESS=false \ - GITHUB_OUTPUT="$tmpdir/github-output" \ -bash "$resolve_script" >"$tmpdir/resolve-output" 2>&1 -resolve_status=$? -set -e -[[ "$resolve_status" -ne 0 ]] || - fail 'removed release asset was accepted when the producer received SIGPIPE' -grep -Fq 'contains a removed Skill-specific asset' "$tmpdir/resolve-output" || - fail 'removed release asset did not fail through the intended release gate' - -cat >"$tmpdir/bin/gh" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail -tag=$3 -case "$tag" in - v0.1.177-codexrip.6) - digest=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - source=1111111111111111111111111111111111111111 - ;; - v0.1.177-codexrip.7) - digest=sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb - source=2222222222222222222222222222222222222222 - ;; - v0.1.177-codexrip.8) - digest=sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc - source=3333333333333333333333333333333333333333 - ;; - *) exit 3 ;; -esac -if [[ "${MOCK_OVERRIDE_TAG:-}" == "$tag" ]]; then - digest=${MOCK_BODY_DIGEST_OVERRIDE:-$digest} - source=${MOCK_BODY_SOURCE_OVERRIDE:-$source} -fi -if [[ " $* " == *' --json assets '* ]]; then - printf '%s\n' 'sub2api-release-checksums.txt' -elif [[ " $* " == *' --json tagName,isDraft,isPrerelease '* ]]; then - draft=false - prerelease=false - if [[ "${MOCK_OVERRIDE_TAG:-}" == "$tag" ]]; then - draft=${MOCK_RELEASE_DRAFT_OVERRIDE:-false} - prerelease=${MOCK_RELEASE_PRERELEASE_OVERRIDE:-false} - fi - printf '%s\t%s\t%s\n' "$tag" "$draft" "$prerelease" -elif [[ " $* " == *' --json body '* ]]; then - version=${tag#v} - printf 'Image: `ghcr.io/htexplicit/sub2api:%s@%s`\nSource: `%s`\n' "$version" "$digest" "$source" -else - exit 2 -fi -EOF -cat >"$tmpdir/bin/docker" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail image_ref=$4 case "$image_ref" in *:0.1.177-codexrip.6*) - tag=v0.1.177-codexrip.6 digest=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa revision=1111111111111111111111111111111111111111 - platform_v1=false ;; *:0.1.177-codexrip.7*) - tag=v0.1.177-codexrip.7 digest=sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb revision=2222222222222222222222222222222222222222 - platform_v1=true - ;; - *:0.1.177-codexrip.8*) - tag=v0.1.177-codexrip.8 - digest=sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc - revision=3333333333333333333333333333333333333333 - platform_v1=true ;; *) exit 3 ;; esac -if [[ "${MOCK_OVERRIDE_TAG:-}" == "$tag" ]]; then - digest=${MOCK_REGISTRY_DIGEST_OVERRIDE:-$digest} - revision=${MOCK_IMAGE_REVISION_OVERRIDE:-$revision} - platform_v1=${MOCK_IMAGE_PLATFORM_V1_OVERRIDE:-$platform_v1} -fi -if [[ "$image_ref" == *@sha256:* ]]; then - requested=${image_ref##*@} - [[ "$requested" == "$digest" ]] || exit 3 -fi -if [[ " $* " == *'io.github.htexplicit.cindy-platform-v1'* ]]; then - printf '%s\n' "$platform_v1" -elif [[ " $* " == *' --format '* ]]; then - printf '%s\n' "$revision" -else - printf 'Name: image\nDigest: %s\n' "$digest" -fi +[[ "$image_ref" == *@"$digest" ]] || exit 3 +[[ "$6" == '{{ index .Image.Config.Labels "org.opencontainers.image.revision" }}' ]] || exit 2 +printf '%s\n' "${MOCK_IMAGE_REVISION:-$revision}" EOF cat >"$tmpdir/bin/ssh" <<'EOF' #!/usr/bin/env bash set -euo pipefail -: "${SSH_CAPTURE:?}" -: "${SSH_CALLS:?}" -printf 'call\n' >>"$SSH_CALLS" -printf '%s\n' "$@" >"$SSH_CAPTURE" +printf 'call\n' >>"${SSH_CALLS:?}" +printf '%s\n' "$@" >"${SSH_CAPTURE:?}" EOF -chmod +x "$tmpdir/bin/gh" "$tmpdir/bin/docker" "$tmpdir/bin/ssh" +chmod +x "$tmpdir/bin/gh" "$tmpdir/bin/git" "$tmpdir/bin/docker" "$tmpdir/bin/ssh" run_resolve() { - local operation=$1 tag=$2 expected_current=$3 confirmation=$4 interrupt=${5-false} : >"$tmpdir/github-output" - : >"$tmpdir/resolve-output" PATH="$tmpdir/bin:$PATH" \ - MOCK_OVERRIDE_TAG="${MOCK_OVERRIDE_TAG:-}" \ - MOCK_RELEASE_DRAFT_OVERRIDE="${MOCK_RELEASE_DRAFT_OVERRIDE:-}" \ - MOCK_RELEASE_PRERELEASE_OVERRIDE="${MOCK_RELEASE_PRERELEASE_OVERRIDE:-}" \ - MOCK_BODY_DIGEST_OVERRIDE="${MOCK_BODY_DIGEST_OVERRIDE:-}" \ - MOCK_BODY_SOURCE_OVERRIDE="${MOCK_BODY_SOURCE_OVERRIDE:-}" \ - MOCK_REGISTRY_DIGEST_OVERRIDE="${MOCK_REGISTRY_DIGEST_OVERRIDE:-}" \ - MOCK_IMAGE_REVISION_OVERRIDE="${MOCK_IMAGE_REVISION_OVERRIDE:-}" \ - MOCK_IMAGE_PLATFORM_V1_OVERRIDE="${MOCK_IMAGE_PLATFORM_V1_OVERRIDE:-}" \ - OPERATION="$operation" \ - RELEASE_TAG="$tag" \ - EXPECTED_CURRENT_RELEASE_TAG="$expected_current" \ - CONFIRMATION="$confirmation" \ - CINDY_HEALTH=true \ - CINDY_CAPABILITY_CATALOG=false \ - CINDY_SEARCH=false \ - IMAGE_STUDIO=false \ - CINDY_RESPONSES_IMAGE_BRIDGE=false \ - INTERRUPT_BUSINESS="$interrupt" \ - GITHUB_OUTPUT="$tmpdir/github-output" \ + MOCK_RELEASE_DRAFT="${MOCK_RELEASE_DRAFT:-false}" \ + MOCK_RELEASE_PRERELEASE="${MOCK_RELEASE_PRERELEASE:-false}" \ + MOCK_BODY_DIGEST="${MOCK_BODY_DIGEST:-}" \ + MOCK_BODY_IMAGE="${MOCK_BODY_IMAGE:-}" \ + MOCK_BODY_SOURCE="${MOCK_BODY_SOURCE:-}" \ + MOCK_DUPLICATE_IMAGE="${MOCK_DUPLICATE_IMAGE:-false}" \ + MOCK_IMAGE_REVISION="${MOCK_IMAGE_REVISION:-}" \ + MOCK_NON_ANCESTOR="${MOCK_NON_ANCESTOR:-false}" \ + RELEASE_TAG="$1" CONFIRMATION="$2" GITHUB_OUTPUT="$tmpdir/github-output" \ bash "$resolve_script" >"$tmpdir/resolve-output" 2>&1 } -run_apply() { - local operation=$1 image_ref=$2 expected_current=$3 rollout=$4 runtime_spec=${5-runtime=explicit} maintenance_spec=${6-} - : >"$tmpdir/ssh-capture" - : >"$tmpdir/ssh-calls" - : >"$tmpdir/apply-output" - HOME="$tmpdir/home" \ - PATH="$tmpdir/bin:$PATH" \ - SSH_CAPTURE="$tmpdir/ssh-capture" \ - SSH_CALLS="$tmpdir/ssh-calls" \ - VPS_HOST=production.example.invalid \ - VPS_PORT="${MOCK_VPS_PORT:-2222}" \ - VPS_USER=deployer \ - OPERATION="$operation" \ - IMAGE_REF="$image_ref" \ - EXPECTED_CURRENT_IMAGE_REF="$expected_current" \ - CINDY_ROLLOUT="$rollout" \ - MAINTENANCE_SPEC="$maintenance_spec" \ - RUNTIME_SPEC="$runtime_spec" \ - bash "$apply_script" >"$tmpdir/apply-output" 2>&1 -} - -assert_ssh_invocation() { - local expected_remote_command=$1 index line - local -a actual expected - actual=() - while IFS= read -r line; do - actual+=("$line") - done <"$tmpdir/ssh-capture" - expected=( - -F /dev/null - -i "$tmpdir/home/.ssh/sub2api_deploy" - -o BatchMode=yes - -o IdentitiesOnly=yes - -o ServerAliveInterval=30 - -o ServerAliveCountMax=10 - -o StrictHostKeyChecking=yes - -o "UserKnownHostsFile=$tmpdir/home/.ssh/known_hosts" - -p 2222 - deployer@production.example.invalid - "$expected_remote_command" - ) - [[ "$(wc -l <"$tmpdir/ssh-calls")" -eq 1 ]] || - fail 'immutable release operation did not invoke ssh exactly once' - [[ "${#actual[@]}" -eq "${#expected[@]}" ]] || - fail "ssh argv length mismatch: expected ${#expected[@]}, got ${#actual[@]}" - for ((index = 0; index < ${#expected[@]}; index++)); do - [[ "${actual[$index]}" == "${expected[$index]}" ]] || - fail "ssh argv[$index] mismatch: expected '${expected[$index]}', got '${actual[$index]}'" - done -} - -assert_ssh_not_invoked() { - [[ ! -s "$tmpdir/ssh-calls" && ! -s "$tmpdir/ssh-capture" ]] || - fail 'rejected immutable release operation still invoked ssh' +assert_resolve_rejected() { + if run_resolve "$1" "$2"; then fail "$3"; fi + [[ ! -s "$tmpdir/github-output" ]] || fail 'rejected release emitted a deployable image' } -rollback_target_ref=ghcr.io/htexplicit/sub2api:0.1.177-codexrip.6@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -rollback_current_ref=ghcr.io/htexplicit/sub2api:0.1.177-codexrip.7@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb - -if ! run_resolve deploy v0.1.177-codexrip.7 '' DEPLOY; then - cat "$tmpdir/resolve-output" >&2 - fail 'valid deploy resolution did not execute successfully' -fi -grep -Fq "image_ref=$rollback_current_ref" "$tmpdir/github-output" || - fail 'deploy did not emit the Release-bound immutable image' -grep -Fxq 'expected_current_image_ref=' "$tmpdir/github-output" || - fail 'deploy unexpectedly emitted an expected-current rollback image' - -if ! run_resolve reconcile-runtime v0.1.177-codexrip.7 '' RECONCILE; then - cat "$tmpdir/resolve-output" >&2 - fail 'valid runtime reconciliation did not resolve successfully' -fi -grep -Fq "image_ref=$rollback_current_ref" "$tmpdir/github-output" || - fail 'runtime reconciliation did not resolve its exact current Release image' -grep -Fxq 'expected_current_image_ref=' "$tmpdir/github-output" || - fail 'runtime reconciliation unexpectedly emitted a second current image' -grep -Fxq 'operation=reconcile-runtime' "$tmpdir/github-output" || - fail 'runtime reconciliation did not preserve its dedicated operation' -grep -Fxq 'runtime_spec=runtime=explicit' "$tmpdir/github-output" || - fail 'runtime reconciliation did not require an explicit target runtime tuple' -if run_resolve reconcile-runtime v0.1.177-codexrip.7 '' DEPLOY; then - fail 'runtime reconciliation accepted the deploy confirmation word' -fi -if run_resolve reconcile-runtime v0.1.177-codexrip.7 v0.1.177-codexrip.6 RECONCILE; then - fail 'runtime reconciliation accepted a separate expected-current release' -fi -if ! run_resolve reconcile-runtime v0.1.177-codexrip.7 '' RECONCILE true; then - cat "$tmpdir/resolve-output" >&2 - fail 'runtime reconciliation rejected the explicit interruption mode' -fi -grep -Fxq 'maintenance_spec=maintenance=interrupt' "$tmpdir/github-output" || - fail 'runtime reconciliation did not emit the explicit interruption spec' - -run_resolve deploy v0.1.177-codexrip.7 '' DEPLOY true || - fail 'deploy resolution rejected the explicit interruption mode' -grep -Fxq 'maintenance_spec=maintenance=interrupt' "$tmpdir/github-output" || - fail 'deploy resolution did not emit the explicit interruption spec' - -run_resolve rollback v0.1.177-codexrip.6 v0.1.177-codexrip.7 ROLLBACK || - fail 'valid rollback resolution did not execute successfully' -grep -Fq "image_ref=$rollback_target_ref" "$tmpdir/github-output" || - fail 'rollback did not emit the immutable target image' -grep -Fq "expected_current_image_ref=$rollback_current_ref" "$tmpdir/github-output" || - fail 'rollback did not emit the immutable expected-current image' - -MOCK_OVERRIDE_TAG=v0.1.177-codexrip.7 -MOCK_BODY_DIGEST_OVERRIDE=sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd -if run_resolve deploy v0.1.177-codexrip.7 '' DEPLOY; then - fail 'registry digest was accepted when it did not match the Release body' -fi -unset MOCK_BODY_DIGEST_OVERRIDE -MOCK_BODY_SOURCE_OVERRIDE=4444444444444444444444444444444444444444 -if run_resolve deploy v0.1.177-codexrip.7 '' DEPLOY; then - fail 'Release source was accepted when it did not match the release tag commit' -fi -unset MOCK_BODY_SOURCE_OVERRIDE -MOCK_IMAGE_REVISION_OVERRIDE=4444444444444444444444444444444444444444 -if run_resolve deploy v0.1.177-codexrip.7 '' DEPLOY; then - fail 'immutable image was accepted when its OCI revision did not match the release tag commit' -fi -unset MOCK_IMAGE_REVISION_OVERRIDE -MOCK_IMAGE_PLATFORM_V1_OVERRIDE=false -if run_resolve deploy v0.1.177-codexrip.7 '' DEPLOY; then - fail 'immutable image without the Cindy platform-v1 capability label was accepted' -fi -unset MOCK_IMAGE_PLATFORM_V1_OVERRIDE -MOCK_RELEASE_DRAFT_OVERRIDE=true -if run_resolve deploy v0.1.177-codexrip.7 '' DEPLOY; then - fail 'draft Release was accepted for production deploy' -fi -unset MOCK_RELEASE_DRAFT_OVERRIDE MOCK_OVERRIDE_TAG - -MOCK_OVERRIDE_TAG=v0.1.177-codexrip.7 -MOCK_BODY_DIGEST_OVERRIDE=sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd -if run_resolve rollback v0.1.177-codexrip.6 v0.1.177-codexrip.7 ROLLBACK; then - fail 'rollback accepted an expected-current Release body with the wrong digest' -fi -unset MOCK_BODY_DIGEST_OVERRIDE -MOCK_BODY_SOURCE_OVERRIDE=4444444444444444444444444444444444444444 -if run_resolve rollback v0.1.177-codexrip.6 v0.1.177-codexrip.7 ROLLBACK; then - fail 'rollback accepted an expected-current Release body with the wrong source' -fi -unset MOCK_BODY_SOURCE_OVERRIDE -MOCK_IMAGE_REVISION_OVERRIDE=4444444444444444444444444444444444444444 -if run_resolve rollback v0.1.177-codexrip.6 v0.1.177-codexrip.7 ROLLBACK; then - fail 'rollback accepted an expected-current image with the wrong OCI revision' -fi -unset MOCK_IMAGE_REVISION_OVERRIDE -MOCK_IMAGE_PLATFORM_V1_OVERRIDE=false -if run_resolve rollback v0.1.177-codexrip.6 v0.1.177-codexrip.7 ROLLBACK; then - fail 'rollback accepted an expected-current image without the platform-v1 capability label' -fi -unset MOCK_IMAGE_PLATFORM_V1_OVERRIDE MOCK_OVERRIDE_TAG -if run_resolve rollback v0.1.177-codexrip.8 v0.1.177-codexrip.7 ROLLBACK; then - fail 'rollback target newer than expected-current was accepted' -fi -if run_resolve rollback v0.1.177-codexrip.6 v0.1.177-codexrip.7 ROLLBACK true; then - fail 'rollback accepted the business interruption mode' -fi - -run_apply deploy "$rollback_current_ref" '' 'cindy=true,true,true,false,false' || - fail 'valid deploy operation did not execute successfully' -assert_ssh_invocation "deploy $rollback_current_ref cindy=true,true,true,false,false" - -run_apply rollback "$rollback_target_ref" "$rollback_current_ref" 'cindy=true,false,false,false,false' || - fail 'valid rollback operation did not execute successfully' -assert_ssh_invocation "rollback $rollback_target_ref from=$rollback_current_ref cindy=true,false,false,false,false" - -run_apply deploy "$rollback_current_ref" '' 'cindy=true,true,true,false,false' runtime=explicit maintenance=interrupt || - fail 'valid maintenance-interrupt deploy operation did not execute successfully' -assert_ssh_invocation "deploy $rollback_current_ref cindy=true,true,true,false,false maintenance=interrupt" - -run_apply deploy "$rollback_current_ref" '' '' runtime=preserve || - fail 'valid runtime-preserve deploy operation did not execute successfully' -assert_ssh_invocation "deploy $rollback_current_ref runtime=preserve" - -run_apply reconcile-runtime "$rollback_current_ref" '' 'cindy=true,true,true,false,false' || - fail 'valid runtime reconciliation did not execute successfully' -assert_ssh_invocation "reconcile-runtime $rollback_current_ref cindy=true,true,true,false,false" - -run_apply reconcile-runtime "$rollback_current_ref" '' 'cindy=true,true,true,false,false' runtime=explicit maintenance=interrupt || - fail 'valid interrupted runtime reconciliation did not execute successfully' -assert_ssh_invocation "reconcile-runtime $rollback_current_ref cindy=true,true,true,false,false maintenance=interrupt" - -if run_apply reconcile-runtime "$rollback_current_ref" "$rollback_target_ref" 'cindy=true,true,true,false,false'; then - fail 'runtime reconciliation accepted a second expected-current image' -fi -assert_ssh_not_invoked -if run_apply reconcile-runtime "$rollback_current_ref" '' 'cindy=true,true,true,false,false' runtime=preserve; then - fail 'runtime reconciliation accepted runtime-preserve mode' -fi -assert_ssh_not_invoked -if run_apply deploy "$rollback_current_ref" "$rollback_target_ref" 'cindy=true,true,true,false,false'; then - fail 'deploy accepted an unexpected expected-current image' -fi -assert_ssh_not_invoked -if run_apply rollback "$rollback_target_ref" "$rollback_target_ref" 'cindy=true,false,false,false,false'; then - fail 'rollback accepted a target equal to expected-current' -fi -assert_ssh_not_invoked -MOCK_VPS_PORT=not-a-port -if run_apply deploy "$rollback_current_ref" '' 'cindy=true,true,true,false,false'; then - fail 'deploy accepted a malformed SSH port' -fi -unset MOCK_VPS_PORT -assert_ssh_not_invoked -if run_apply deploy 'ghcr.io/htexplicit/sub2api:latest' '' 'cindy=true,true,true,false,false'; then - fail 'deploy accepted a mutable image reference' -fi -assert_ssh_not_invoked -if run_apply deploy "$rollback_current_ref" '' 'cindy=true,false,true'; then - fail 'deploy accepted a malformed Cindy rollout' -fi -assert_ssh_not_invoked -if run_apply rollback "$rollback_target_ref" '' 'cindy=true,false,false,false,false'; then - fail 'rollback accepted an empty expected-current image' -fi -assert_ssh_not_invoked -if run_apply invalid "$rollback_current_ref" '' 'cindy=true,true,true,false,false'; then - fail 'immutable release operation accepted an unknown operation' -fi -assert_ssh_not_invoked - -inspect=$'Name: image\nDigest: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nManifest: amd64\nManifest: attestation' -digest=$(awk '$1 == "Digest:" && digest == "" {digest=$2} END {if (digest == "") exit 1; print digest}' <<<"$inspect") -[[ "$digest" == 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' ]] || - fail 'digest parser did not preserve the first index digest' - -for input in operation release_tag expected_current_release_tag confirmation \ - cindy_health cindy_capability_catalog cindy_search image_studio \ - cindy_responses_image_bridge interrupt_business resource_profile; do - grep -Fq " ${input}:" "$WORKFLOW" || fail "missing typed workflow input: $input" +# Both old and new immutable images use the same path, without fork labels. +for tag in v0.1.177-codexrip.6 v0.1.177-codexrip.7; do + run_resolve "$tag" DEPLOY || { cat "$tmpdir/resolve-output" >&2; fail 'valid release was rejected'; } + [[ "$(wc -l <"$tmpdir/github-output")" -eq 1 ]] || fail 'resolve must emit only image_ref' + grep -Fq "image_ref=ghcr.io/htexplicit/sub2api:${tag#v}@sha256:" "$tmpdir/github-output" || + fail 'resolve did not emit the immutable release image' done -grep -Fq ' - deploy' "$WORKFLOW" || fail 'workflow operation is missing deploy' -grep -Fq ' - deploy-preserve' "$WORKFLOW" || fail 'workflow operation is missing deploy-preserve' -grep -Fq ' - reconcile-runtime' "$WORKFLOW" || fail 'workflow operation is missing reconcile-runtime' -grep -Fq ' - rollback' "$WORKFLOW" || fail 'workflow operation is missing rollback' -[[ "$(grep -c ' type: boolean' "$WORKFLOW")" -ge 5 ]] || - fail 'Cindy rollout inputs must be typed booleans' -[[ "$(grep -c ' default: false' "$WORKFLOW")" -ge 5 ]] || - fail 'all staged rollout phases must default off' -if grep -Fq 'requires the Cindy capability catalog' "$WORKFLOW"; then - fail 'independent Search, Image Studio, or Responses-image flags still depend on the catalog' -fi -grep -Fq 'cindy_rollout=cindy=${CINDY_HEALTH},${CINDY_CAPABILITY_CATALOG},${CINDY_SEARCH},${IMAGE_STUDIO},${CINDY_RESPONSES_IMAGE_BRIDGE}' "$WORKFLOW" || - fail 'resolve must emit a canonical Cindy rollout tuple' -grep -Fq 'io.github.htexplicit.cindy-platform-v1' "$WORKFLOW" || - fail 'resolve must require the platform capability label from the immutable image' -grep -Fq 'expected_current_image_ref=${expected_current_image_ref}' "$WORKFLOW" || - fail 'resolve must publish the immutable expected-current rollback image' -grep -Fq '[[ "$CONFIRMATION" == DEPLOY ]]' "$WORKFLOW" || - fail 'deploy must require the exact DEPLOY confirmation' -grep -Fq '[[ "$CONFIRMATION" == ROLLBACK ]]' "$WORKFLOW" || - fail 'rollback must require the exact ROLLBACK confirmation' -grep -Fq '[[ "$CONFIRMATION" == RECONCILE ]]' "$WORKFLOW" || - fail 'runtime reconciliation must require the exact RECONCILE confirmation' -grep -Fq 'resolve_release_image "$EXPECTED_CURRENT_RELEASE_TAG"' "$WORKFLOW" || - fail 'rollback must independently resolve the expected-current release image' -grep -Fq 'resolve_release_image "$RELEASE_TAG" rollback-target' "$WORKFLOW" || - fail 'rollback must resolve the target using the legacy-aware image role' -grep -Fq 'resolve_release_image "$EXPECTED_CURRENT_RELEASE_TAG" platform' "$WORKFLOW" || - fail 'rollback must require the expected-current image to be platform capable' -grep -Fq 'isDraft,isPrerelease' "$WORKFLOW" || - fail 'release state resolution must inspect draft and prerelease flags' -grep -Fq '\tfalse\tfalse' "$WORKFLOW" || - fail 'deploy and rollback releases must be published and non-prerelease' -grep -Fq 'version_strictly_less "$RELEASE_TAG" "$EXPECTED_CURRENT_RELEASE_TAG"' "$WORKFLOW" || - fail 'rollback must require the target release to be older than expected-current' -grep -Fq 'remote_command="deploy ${IMAGE_REF} ${CINDY_ROLLOUT}"' "$WORKFLOW" || - fail 'deploy must pass the canonical rollout tuple to the forced command' -grep -Fq 'remote_command+=" $RESOURCE_SPEC"' "$WORKFLOW" || - fail 'deploy must pass an explicit Sub2API resource profile to the forced command' -grep -Fq 'maintenance_spec=maintenance=interrupt' "$WORKFLOW" || - fail 'explicit business interruption must resolve to a fixed maintenance spec' -grep -Fq 'remote_command+=" $MAINTENANCE_SPEC"' "$WORKFLOW" || - fail 'explicit business interruption must be appended only after validation' -grep -Fq '[[ "$OPERATION" == deploy || "$OPERATION" == reconcile-runtime || -z "$MAINTENANCE_SPEC" ]]' "$WORKFLOW" || - fail 'rollback must reject the maintenance interrupt spec while allowing deploy/reconcile' -grep -Fq '[[ "$INTERRUPT_BUSINESS" == false ]]' "$WORKFLOW" || - fail 'preserve/rollback paths must reject business interruption' -grep -Fq 'remote_command="deploy ${IMAGE_REF} runtime=preserve"' "$WORKFLOW" || - fail 'automatic deploy must preserve the locked runtime tuple' -grep -Fq 'remote_command="reconcile-runtime ${IMAGE_REF} ${CINDY_ROLLOUT}"' "$WORKFLOW" || - fail 'runtime reconciliation must bind the exact current image and explicit runtime tuple' -grep -Fq 'remote_command+=" $MAINTENANCE_SPEC"' "$WORKFLOW" || - fail 'runtime reconciliation must append only the validated maintenance interrupt spec' -grep -Fq 'if [[ ("$OPERATION" == deploy || "$OPERATION" == reconcile-runtime) && "$INTERRUPT_BUSINESS" == true ]]; then' "$WORKFLOW" || - fail 'runtime reconciliation interruption was not bound to the fixed maintenance spec' -grep -Fq 'remote_command="rollback ${IMAGE_REF} from=${EXPECTED_CURRENT_IMAGE_REF} ${CINDY_ROLLOUT}"' "$WORKFLOW" || - fail 'rollback must bind the target to the exact expected-current image' - -validate_rollout() { - local value=$1 - [[ "$value" =~ ^cindy=(true|false),(true|false),(true|false),(true|false),(true|false)$ ]] -} - -validate_rollout 'cindy=false,false,false,false,false' || fail 'platform-only rollout was rejected' -validate_rollout 'cindy=false,true,false,false,false' || fail 'catalog rollout was rejected' -validate_rollout 'cindy=true,true,false,false,false' || fail 'health rollout was rejected' -validate_rollout 'cindy=true,true,true,false,false' || fail 'search rollout was rejected' -validate_rollout 'cindy=true,true,true,true,false' || fail 'Image Studio rollout was rejected' -validate_rollout 'cindy=true,true,true,true,true' || fail 'Responses-image rollout was rejected' -validate_rollout 'cindy=false,false,true,true,true' || fail 'independent feature rollout was rejected' -for invalid in \ - 'cindy=1,false,false,false,false' \ - 'cindy=true,false,false,false,false extra' \ - 'cindy=true,false,false,false,false;id' \ - 'cindy=true, true,false,false,false' \ - 'cindy=true,false,false' -do - if validate_rollout "$invalid"; then - fail "malformed rollout tuple was accepted: $invalid" - fi +for confirmation in ROLLBACK RECONCILE deploy ''; do + assert_resolve_rejected v0.1.177-codexrip.7 "$confirmation" 'non-DEPLOY confirmation was accepted' done +for tag in latest v0.1.177 v0.1.177-codexrip.0 'v0.1.177-codexrip.7;id'; do + assert_resolve_rejected "$tag" DEPLOY 'invalid release tag was accepted' +done +MOCK_RELEASE_DRAFT=true +assert_resolve_rejected v0.1.177-codexrip.7 DEPLOY 'draft release was accepted' +unset MOCK_RELEASE_DRAFT +MOCK_RELEASE_PRERELEASE=true +assert_resolve_rejected v0.1.177-codexrip.7 DEPLOY 'prerelease was accepted' +unset MOCK_RELEASE_PRERELEASE +MOCK_BODY_DIGEST=sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd +assert_resolve_rejected v0.1.177-codexrip.7 DEPLOY 'unavailable release digest was accepted' +unset MOCK_BODY_DIGEST +MOCK_BODY_IMAGE=ghcr.io/htexplicit/sub2api:latest +assert_resolve_rejected v0.1.177-codexrip.7 DEPLOY 'wrong image tag was accepted' +unset MOCK_BODY_IMAGE +MOCK_BODY_SOURCE=4444444444444444444444444444444444444444 +assert_resolve_rejected v0.1.177-codexrip.7 DEPLOY 'release source mismatch was accepted' +unset MOCK_BODY_SOURCE +MOCK_IMAGE_REVISION=4444444444444444444444444444444444444444 +assert_resolve_rejected v0.1.177-codexrip.7 DEPLOY 'OCI revision mismatch was accepted' +unset MOCK_IMAGE_REVISION +MOCK_DUPLICATE_IMAGE=true +assert_resolve_rejected v0.1.177-codexrip.7 DEPLOY 'ambiguous release body was accepted' +unset MOCK_DUPLICATE_IMAGE +MOCK_NON_ANCESTOR=true +assert_resolve_rejected v0.1.177-codexrip.7 DEPLOY 'release outside main was accepted' +unset MOCK_NON_ANCESTOR -grep -Fq 'io.github.htexplicit.cindy-platform-v1=true' "$DOWNSTREAM_RELEASE_WORKFLOW" || - fail 'downstream image must declare the platform capability label' -grep -Fq "!contains(github.ref_name, '-codexrip.')" "$GENERIC_RELEASE_WORKFLOW" || - fail 'generic Release workflow must skip downstream codexrip tags' - -version_strictly_less() { - local target=$1 current=$2 first - [[ "$target" != "$current" ]] || return 1 - first=$(printf '%s\n%s\n' "$target" "$current" | LC_ALL=C sort -V | head -n 1) - [[ "$first" == "$target" ]] +run_apply() { + : >"$tmpdir/ssh-capture" + : >"$tmpdir/ssh-calls" + PATH="$tmpdir/bin:$PATH" SSH_CAPTURE="$tmpdir/ssh-capture" SSH_CALLS="$tmpdir/ssh-calls" \ + VPS_HOST=production.example.invalid VPS_PORT="${MOCK_VPS_PORT:-2222}" VPS_USER=deployer \ + IMAGE_REF="$1" bash "$apply_script" >"$tmpdir/apply-output" 2>&1 } -version_strictly_less 'v0.1.177-codexrip.6' 'v0.1.177-codexrip.7' || - fail 'valid rollback order was rejected' -version_strictly_less 'v0.1.176-codexrip.16' 'v0.1.177-codexrip.1' || - fail 'valid cross-upstream rollback order was rejected' -for invalid_pair in \ - 'v0.1.177-codexrip.7 v0.1.177-codexrip.7' \ - 'v0.1.177-codexrip.8 v0.1.177-codexrip.7' \ - 'v0.1.178-codexrip.1 v0.1.177-codexrip.9'; do - read -r target current <<<"$invalid_pair" - if version_strictly_less "$target" "$current"; then - fail "unsafe rollback order was accepted: $invalid_pair" - fi +image_ref=ghcr.io/htexplicit/sub2api:0.1.177-codexrip.7@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +run_apply "$image_ref" || fail 'valid deploy did not execute' +actual=() +while IFS= read -r line; do actual+=("$line"); done <"$tmpdir/ssh-capture" +expected=( + -F /dev/null + -i "$HOME/.ssh/sub2api_deploy" + -o BatchMode=yes + -o IdentitiesOnly=yes + -o ServerAliveInterval=30 + -o ServerAliveCountMax=10 + -o StrictHostKeyChecking=yes + -o "UserKnownHostsFile=$HOME/.ssh/known_hosts" + -p 2222 + deployer@production.example.invalid + "deploy $image_ref" +) +[[ "$(wc -l <"$tmpdir/ssh-calls")" -eq 1 ]] || fail 'deploy must invoke SSH exactly once' +[[ "${#actual[@]}" -eq "${#expected[@]}" ]] || fail 'SSH argument count mismatch' +for ((i = 0; i < ${#expected[@]}; i++)); do + [[ "${actual[$i]}" == "${expected[$i]}" ]] || fail "SSH argument $i mismatch" done +for invalid in 'ghcr.io/htexplicit/sub2api:latest' "$image_ref runtime=preserve" "$image_ref;id"; do + if run_apply "$invalid"; then fail 'invalid image reference was accepted'; fi + [[ ! -s "$tmpdir/ssh-calls" && ! -s "$tmpdir/ssh-capture" ]] || fail 'rejected reference invoked SSH' +done +MOCK_VPS_PORT=not-a-port +if run_apply "$image_ref"; then fail 'invalid SSH port was accepted'; fi +[[ ! -s "$tmpdir/ssh-calls" && ! -s "$tmpdir/ssh-capture" ]] || fail 'rejected SSH port invoked SSH' +unset MOCK_VPS_PORT -printf 'PASS: production deploy, rollback, digest, and Cindy rollout resolution\n' +printf 'PASS: single production deploy, immutable source binding, and pinned SSH command\n' diff --git a/frontend/src/api/__tests__/downstreamStatus.spec.ts b/frontend/src/api/__tests__/downstreamStatus.spec.ts index 7a00e12b41a3..6da24a3ac3da 100644 --- a/frontend/src/api/__tests__/downstreamStatus.spec.ts +++ b/frontend/src/api/__tests__/downstreamStatus.spec.ts @@ -82,7 +82,7 @@ describe('downstream release status', () => { .mockImplementationOnce(() => response([])) .mockImplementationOnce(() => response({ workflow_runs: run ? [{ id: 1, - display_title: 'Deploy v0.1.185-codexrip.1 (preserve)', + display_title: 'Deploy v0.1.185-codexrip.1', html_url: 'https://production', ...run }] : [] }))