diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index f3201e2..a3f4784 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -9,9 +9,21 @@ on: operation: description: Production action type: choice - options: [deploy, rollback] + options: [deploy, deploy_existing, rollback] default: deploy required: true + publication_run_id: + description: Successful main publication run for deploy_existing + type: string + required: false + source_commit: + description: Full source commit SHA for deploy_existing + type: string + required: false + image_digest: + description: Published sha256 digest for deploy_existing (digest only) + type: string + required: false rollback_run_id: description: Optional saved deployment id for rollback; blank selects latest type: string @@ -26,7 +38,7 @@ concurrency: jobs: test: - if: github.event_name != 'workflow_dispatch' || inputs.operation == 'deploy' + if: github.event_name != 'workflow_dispatch' || inputs.operation == 'deploy' || inputs.operation == 'deploy_existing' runs-on: ubuntu-latest permissions: contents: read @@ -83,7 +95,10 @@ jobs: docker run --rm --network none --volume "$GITHUB_WORKSPACE/tests:/tmp/codelens-tests:ro" --volume "$GITHUB_WORKSPACE/scripts:/tmp/scripts:ro" + --volume "$GITHUB_WORKSPACE/.github/workflows/production.yml:/tmp/production.yml:ro" --volume "$GITHUB_WORKSPACE/pytest.ini:/tmp/pytest.ini:ro" + --env CODELENS_WORKFLOW_FILE=/tmp/production.yml + --env PYTHONPATH=/tmp --env DEBUG=false --env DATABASE_URL=postgresql+asyncpg://test:test@127.0.0.1/test --env REDIS_URL=redis://127.0.0.1:6379/0 @@ -134,7 +149,10 @@ jobs: docker run --rm --network none --volume "$GITHUB_WORKSPACE/tests:/tmp/codelens-tests:ro" --volume "$GITHUB_WORKSPACE/scripts:/tmp/scripts:ro" + --volume "$GITHUB_WORKSPACE/.github/workflows/production.yml:/tmp/production.yml:ro" --volume "$GITHUB_WORKSPACE/pytest.ini:/tmp/pytest.ini:ro" + --env CODELENS_WORKFLOW_FILE=/tmp/production.yml + --env PYTHONPATH=/tmp --env DEBUG=false --env DATABASE_URL=postgresql+asyncpg://test:test@127.0.0.1/test --env REDIS_URL=redis://127.0.0.1:6379/0 @@ -171,12 +189,44 @@ jobs: [[ "$digest" =~ ^sha256:[a-f0-9]{64}$ ]] echo "digest=$digest" >> "$GITHUB_OUTPUT" + verify_existing: + needs: test + if: github.event_name == 'workflow_dispatch' && inputs.operation == 'deploy_existing' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + packages: read + outputs: + image: ${{ steps.verify.outputs.image }} + source_commit: ${{ steps.verify.outputs.source_commit }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - name: Verify prior tested publication and immutable digest + id: verify + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_ACTOR: ${{ github.actor }} + PUBLICATION_RUN_ID: ${{ inputs.publication_run_id }} + SOURCE_COMMIT: ${{ inputs.source_commit }} + IMAGE_DIGEST: ${{ inputs.image_digest }} + shell: bash + run: | + set -euo pipefail + python3 scripts/verify_ghcr_publication.py \ + --publication-run-id "$PUBLICATION_RUN_ID" \ + --source-commit "$SOURCE_COMMIT" \ + --image-digest "$IMAGE_DIGEST" + deploy: - needs: publish + needs: [publish, verify_existing] if: >- - github.ref == 'refs/heads/main' && needs.publish.result == 'success' && - ((github.event_name == 'workflow_dispatch' && inputs.operation == 'deploy') || - (github.event_name == 'push' && vars.CODELENS_AUTO_DEPLOY == 'true')) + always() && github.ref == 'refs/heads/main' && + ((github.event_name == 'workflow_dispatch' && inputs.operation == 'deploy' && needs.publish.result == 'success') || + (github.event_name == 'workflow_dispatch' && inputs.operation == 'deploy_existing' && needs.verify_existing.result == 'success') || + (github.event_name == 'push' && needs.publish.result == 'success' && vars.CODELENS_AUTO_DEPLOY == 'true')) runs-on: ubuntu-latest environment: production permissions: @@ -227,8 +277,8 @@ jobs: echo "ssh_known_hosts=$SSH_DIR/known_hosts" >> "$GITHUB_ENV" - name: Deploy exact published image digest env: - IMAGE: ${{ needs.publish.outputs.image }}@${{ needs.publish.outputs.digest }} - EXPECTED_SHA: ${{ github.sha }} + IMAGE: ${{ inputs.operation == 'deploy_existing' && needs.verify_existing.outputs.image || format('{0}@{1}', needs.publish.outputs.image, needs.publish.outputs.digest) }} + EXPECTED_SHA: ${{ inputs.operation == 'deploy_existing' && needs.verify_existing.outputs.source_commit || github.sha }} GHCR_USERNAME: ${{ github.actor }} shell: bash run: | diff --git a/docs/ghcr-deployment.md b/docs/ghcr-deployment.md index de039ea..7eef968 100644 --- a/docs/ghcr-deployment.md +++ b/docs/ghcr-deployment.md @@ -49,17 +49,39 @@ deployment helper. credentials revoked. Keep the replacement values only in their protected provider settings and production `.env`; never paste or print them in Actions logs or deployment diagnostics. -3. On the `main` branch, run Actions → **Test, publish, and deploy CodeLens** → - Run workflow → `deploy`. The image is published only after both test runs - pass. Approve the `production` environment job only after reviewing the - commit and published image digest. -4. The EC2 helper verifies the digest's embedded source commit and `linux/amd64` +3. For the already-published first-release image, open Actions → **Test, publish, + and deploy CodeLens** → **Run workflow**, select branch `main`, and set: + - `operation`: `deploy_existing` + - `publication_run_id`: the numeric run ID from the successful publication + run's URL (not the UI run number). For the reported run #9, copy the ID from + its `/actions/runs/` URL. + - `source_commit`: + `b3c7a3cc74336272b907c10eb32ca874aaa908cb` + - `image_digest`: + `sha256:c2da031dca76bccab86e95b05a469048dc876d926c5ba978a0e0e41fba7b2e3a` + The verification job checks that this is a successful main publication from + this workflow, that its test and image-publish steps succeeded, that the + commit tag resolves to the supplied immutable digest, and that the image + config identifies the same source commit and `linux/amd64`. It reads registry + manifests/config only; it does not pull, rebuild, or republish the image. +4. The `deploy_existing` mode runs the current workflow's tests and then the + verifier. It skips the publisher, so it cannot collide with or overwrite the + source commit tag. The existing `deploy` mode remains for a newly unpublished + main commit: it tests, builds, publishes, and deploys that same run's digest. +5. Confirm the `production` environment has a required reviewer. Approve the + deployment job only after checking the source commit and digest in the run. + Keep `CODELENS_AUTO_DEPLOY` absent or `false`. +6. Immediately before approval, confirm the deployment's Celery gate is clear: + active, reserved, scheduled, unacknowledged, and unacknowledged-index counts + must all be zero. If inspection is unavailable or any count is nonzero, the + helper aborts before changing services. +7. The EC2 helper verifies the digest's embedded source commit and `linux/amd64` platform, checks free space and inodes, and requires the worker to report no active, reserved, scheduled, or unacknowledged Celery deliveries. If any inspection is unavailable or work remains in flight, it stops without changing services. Ready messages may remain queued in Redis; the worker consumes them after restart. -5. The helper tags each current service image by its exact image ID, pulls the +8. The helper tags each current service image by its exact image ID, pulls the GHCR digest once, checks post-pull headroom, and updates only `web`, `worker`, and `flower` in project `codelens`. It checks local and public FastAPI health, Celery worker health and ping, and Flower reachability. Failure triggers an diff --git a/scripts/verify_ghcr_publication.py b/scripts/verify_ghcr_publication.py new file mode 100644 index 0000000..43daec5 --- /dev/null +++ b/scripts/verify_ghcr_publication.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Verify a prior trusted CodeLens GHCR publication without pulling its image.""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import os +import re +import sys +from typing import Any +from urllib.error import URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + + +REPOSITORY = "sprahasingh/CodeLens" +IMAGE_REPOSITORY = "sprahasingh/codelens" +WORKFLOW_PATH = ".github/workflows/production.yml" +ACCEPT_MANIFESTS = ", ".join( + ( + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", + ) +) + + +class VerificationError(Exception): + """A verification failed; details are deliberately not emitted to logs.""" + + +def _request(url: str, headers: dict[str, str]) -> tuple[dict[str, str], bytes]: + request = Request(url, headers=headers) + try: + with urlopen(request, timeout=20) as response: + body = response.read(5_000_001) + if len(body) > 5_000_000: + raise VerificationError + return dict(response.headers.items()), body + except VerificationError: + raise + except (URLError, OSError, ValueError, TimeoutError) as exc: + raise VerificationError from exc + + +def _json_body(body: bytes) -> dict[str, Any]: + try: + value = json.loads(body) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise VerificationError from exc + if not isinstance(value, dict): + raise VerificationError + return value + + +def _github_json(url: str, token: str) -> dict[str, Any]: + _, body = _request( + url, + { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "CodeLens-existing-image-verifier", + }, + ) + return _json_body(body) + + +def _digest_matches(raw: bytes, expected: str, headers: dict[str, str]) -> bool: + actual = "sha256:" + hashlib.sha256(raw).hexdigest() + header_digest = next( + (value for key, value in headers.items() if key.lower() == "docker-content-digest"), + actual, + ) + return actual == expected and header_digest == expected + + +def _registry_headers(token: str, *, accept: bool = False) -> dict[str, str]: + headers = {"Authorization": f"Bearer {token}"} + if accept: + headers["Accept"] = ACCEPT_MANIFESTS + return headers + + +def _registry_token(username: str, github_token: str) -> str: + basic = base64.b64encode(f"{username}:{github_token}".encode()).decode() + url = "https://ghcr.io/token?" + urlencode( + {"service": "ghcr.io", "scope": f"repository:{IMAGE_REPOSITORY}:pull"} + ) + _, body = _request(url, {"Authorization": f"Basic {basic}"}) + payload = _json_body(body) + token = payload.get("token") or payload.get("access_token") + if not isinstance(token, str) or not token: + raise VerificationError + return token + + +def _fetch_manifest(reference: str, registry_token: str) -> tuple[dict[str, Any], dict[str, str], bytes]: + url = f"https://ghcr.io/v2/{IMAGE_REPOSITORY}/manifests/{reference}" + headers, body = _request(url, _registry_headers(registry_token, accept=True)) + return _json_body(body), headers, body + + +def _verify_config(config_digest: str, registry_token: str, source_commit: str) -> None: + if not re.fullmatch(r"sha256:[a-f0-9]{64}", config_digest): + raise VerificationError + url = f"https://ghcr.io/v2/{IMAGE_REPOSITORY}/blobs/{config_digest}" + headers, body = _request(url, _registry_headers(registry_token)) + if not _digest_matches(body, config_digest, headers): + raise VerificationError + config = _json_body(body) + if config.get("os") != "linux" or config.get("architecture") != "amd64": + raise VerificationError + labels = config.get("config", {}).get("Labels", {}) + if not isinstance(labels, dict) or labels.get("org.opencontainers.image.revision") != source_commit: + raise VerificationError + + +def _verify_registry_image(source_commit: str, expected_digest: str, github_token: str, username: str) -> None: + registry_token = _registry_token(username, github_token) + index, headers, raw = _fetch_manifest(source_commit, registry_token) + if not _digest_matches(raw, expected_digest, headers): + raise VerificationError + + media_type = index.get("mediaType", "") + if media_type in { + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + }: + descriptors = index.get("manifests") + if not isinstance(descriptors, list): + raise VerificationError + amd64 = [ + item + for item in descriptors + if isinstance(item, dict) + and isinstance(item.get("platform"), dict) + and item["platform"].get("architecture") == "amd64" + and item["platform"].get("os") == "linux" + ] + if len(amd64) != 1: + raise VerificationError + child_digest = amd64[0].get("digest") + if not isinstance(child_digest, str) or not re.fullmatch(r"sha256:[a-f0-9]{64}", child_digest): + raise VerificationError + child, child_headers, child_raw = _fetch_manifest(child_digest, registry_token) + if not _digest_matches(child_raw, child_digest, child_headers): + raise VerificationError + manifest = child + elif media_type in { + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", + }: + manifest = index + else: + raise VerificationError + + config_digest = manifest.get("config", {}).get("digest") + if not isinstance(config_digest, str): + raise VerificationError + _verify_config(config_digest, registry_token, source_commit) + + +def verify_publication( + *, publication_run_id: str, source_commit: str, image_digest: str, github_token: str, username: str +) -> str: + if not re.fullmatch(r"[1-9][0-9]{0,19}", publication_run_id): + raise VerificationError + if not re.fullmatch(r"[a-f0-9]{40}", source_commit): + raise VerificationError + if not re.fullmatch(r"sha256:[a-f0-9]{64}", image_digest): + raise VerificationError + if not github_token or not username or "\n" in username: + raise VerificationError + + api = f"https://api.github.com/repos/{REPOSITORY}/actions/runs/{publication_run_id}" + run = _github_json(api, github_token) + path = str(run.get("path", "")).split("@", 1)[0] + repo = run.get("repository", {}).get("full_name") + if ( + path != WORKFLOW_PATH + or repo != REPOSITORY + or run.get("head_branch") != "main" + or run.get("head_sha") != source_commit + or run.get("event") not in {"push", "workflow_dispatch"} + or run.get("status") != "completed" + or run.get("conclusion") != "success" + ): + raise VerificationError + + jobs_url = f"https://api.github.com/repos/{REPOSITORY}/actions/runs/{publication_run_id}/jobs?per_page=100" + jobs = _github_json(jobs_url, github_token).get("jobs") + if not isinstance(jobs, list): + raise VerificationError + job_by_name = {job.get("name"): job for job in jobs if isinstance(job, dict)} + test_job = job_by_name.get("test") + publish_job = job_by_name.get("publish") + if not isinstance(test_job, dict) or test_job.get("conclusion") != "success": + raise VerificationError + if not isinstance(publish_job, dict) or publish_job.get("conclusion") != "success": + raise VerificationError + successful_steps = { + step.get("name") + for step in publish_job.get("steps", []) + if isinstance(step, dict) and step.get("conclusion") == "success" + } + if not { + "Run full suite inside the exact production image", + "Push the exact tested image to GHCR", + }.issubset(successful_steps): + raise VerificationError + + _verify_registry_image(source_commit, image_digest, github_token, username) + return f"ghcr.io/{IMAGE_REPOSITORY}@{image_digest}" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--publication-run-id", required=True) + parser.add_argument("--source-commit", required=True) + parser.add_argument("--image-digest", required=True) + args = parser.parse_args(argv) + try: + image = verify_publication( + publication_run_id=args.publication_run_id, + source_commit=args.source_commit, + image_digest=args.image_digest, + github_token=os.environ.get("GITHUB_TOKEN", ""), + username=os.environ.get("GITHUB_ACTOR", ""), + ) + output_file = os.environ.get("GITHUB_OUTPUT") + if not output_file: + raise VerificationError + with open(output_file, "a", encoding="utf-8") as output: + output.write(f"image={image}\nsource_commit={args.source_commit}\n") + print("EXISTING_IMAGE_VERIFICATION: PASS") + return 0 + except Exception: + print("EXISTING_IMAGE_VERIFICATION: FAIL", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_verify_ghcr_publication.py b/tests/test_verify_ghcr_publication.py new file mode 100644 index 0000000..96c153a --- /dev/null +++ b/tests/test_verify_ghcr_publication.py @@ -0,0 +1,252 @@ +"""Offline tests for trusted, digest-pinned GHCR publication verification.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +from urllib.error import HTTPError + +import pytest + +from scripts import verify_ghcr_publication as verifier + + +SOURCE_COMMIT = "b3c7a3cc74336272b907c10eb32ca874aaa908cb" +IMAGE_DIGEST = "sha256:" + "a" * 64 +GITHUB_TOKEN = "synthetic-github-token-canary" + + +class FakeResponse: + def __init__(self, body: bytes, headers: dict[str, str] | None = None): + self._body = body + self.headers = headers or {} + + def __enter__(self) -> "FakeResponse": + return self + + def __exit__(self, *_: object) -> None: + return None + + def read(self, _limit: int = -1) -> bytes: + return self._body + + +def response_json(value: dict[str, object], headers: dict[str, str] | None = None) -> FakeResponse: + return FakeResponse(json.dumps(value, separators=(",", ":")).encode(), headers) + + +def registry_fixture( + monkeypatch: pytest.MonkeyPatch, + *, + architecture: str = "amd64", + label_commit: str = SOURCE_COMMIT, +) -> str: + config = { + "os": "linux", + "architecture": architecture, + "config": {"Labels": {"org.opencontainers.image.revision": label_commit}}, + } + config_body = json.dumps(config, separators=(",", ":")).encode() + config_digest = "sha256:" + hashlib.sha256(config_body).hexdigest() + manifest = { + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": {"mediaType": "application/vnd.oci.image.config.v1+json", "digest": config_digest}, + "layers": [], + } + manifest_body = json.dumps(manifest, separators=(",", ":")).encode() + manifest_digest = "sha256:" + hashlib.sha256(manifest_body).hexdigest() + + run = { + "path": ".github/workflows/production.yml@refs/heads/main", + "repository": {"full_name": "sprahasingh/CodeLens"}, + "head_branch": "main", + "head_sha": SOURCE_COMMIT, + "event": "push", + "status": "completed", + "conclusion": "success", + } + jobs = { + "jobs": [ + {"name": "test", "conclusion": "success", "steps": []}, + { + "name": "publish", + "conclusion": "success", + "steps": [ + {"name": "Run full suite inside the exact production image", "conclusion": "success"}, + {"name": "Push the exact tested image to GHCR", "conclusion": "success"}, + ], + }, + ] + } + + def fake_urlopen(request: object, timeout: int = 0) -> FakeResponse: + url = request.full_url # type: ignore[attr-defined] + if url.startswith("https://api.github.com/repos/sprahasingh/CodeLens/actions/runs/"): + return response_json(jobs if "/jobs?" in url else run) + if url.startswith("https://ghcr.io/token?"): + return response_json({"token": "synthetic-registry-token"}) + if url == f"https://ghcr.io/v2/sprahasingh/codelens/manifests/{SOURCE_COMMIT}": + return FakeResponse(manifest_body, {"Docker-Content-Digest": manifest_digest}) + if url == f"https://ghcr.io/v2/sprahasingh/codelens/blobs/{config_digest}": + return FakeResponse(config_body, {"Docker-Content-Digest": config_digest}) + raise AssertionError("unexpected verification request") + + monkeypatch.setattr(verifier, "urlopen", fake_urlopen) + return manifest_digest + + +def test_success_selects_only_the_fixed_repository_digest(monkeypatch: pytest.MonkeyPatch) -> None: + digest = registry_fixture(monkeypatch) + image = verifier.verify_publication( + publication_run_id="9", + source_commit=SOURCE_COMMIT, + image_digest=digest, + github_token=GITHUB_TOKEN, + username="test-actor", + ) + assert image == f"ghcr.io/sprahasingh/codelens@{digest}" + + +@pytest.mark.parametrize( + ("source_commit", "image_digest"), + [ + (SOURCE_COMMIT, "sha256:invalid"), + (SOURCE_COMMIT, f"ghcr.io/other/repo@{IMAGE_DIGEST}"), + ], +) +def test_invalid_digest_or_unrelated_reference_is_rejected_without_network( + monkeypatch: pytest.MonkeyPatch, source_commit: str, image_digest: str +) -> None: + def unexpected_request(*_: object, **__: object) -> None: + raise AssertionError("input validation must happen before network access") + + monkeypatch.setattr(verifier, "urlopen", unexpected_request) + with pytest.raises(verifier.VerificationError): + verifier.verify_publication( + publication_run_id="9", + source_commit=source_commit, + image_digest=image_digest, + github_token=GITHUB_TOKEN, + username="test-actor", + ) + + +def test_digest_must_match_registry_content(monkeypatch: pytest.MonkeyPatch) -> None: + registry_fixture(monkeypatch) + with pytest.raises(verifier.VerificationError): + verifier.verify_publication( + publication_run_id="9", + source_commit=SOURCE_COMMIT, + image_digest="sha256:" + "b" * 64, + github_token=GITHUB_TOKEN, + username="test-actor", + ) + + +def test_source_commit_must_match_successful_publication(monkeypatch: pytest.MonkeyPatch) -> None: + digest = registry_fixture(monkeypatch) + with pytest.raises(verifier.VerificationError): + verifier.verify_publication( + publication_run_id="9", + source_commit="0" * 40, + image_digest=digest, + github_token=GITHUB_TOKEN, + username="test-actor", + ) + + +def test_unsupported_architecture_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + digest = registry_fixture(monkeypatch, architecture="arm64") + with pytest.raises(verifier.VerificationError): + verifier.verify_publication( + publication_run_id="9", + source_commit=SOURCE_COMMIT, + image_digest=digest, + github_token=GITHUB_TOKEN, + username="test-actor", + ) + + +def test_image_label_must_match_source_commit(monkeypatch: pytest.MonkeyPatch) -> None: + digest = registry_fixture(monkeypatch, label_commit="0" * 40) + with pytest.raises(verifier.VerificationError): + verifier.verify_publication( + publication_run_id="9", + source_commit=SOURCE_COMMIT, + image_digest=digest, + github_token=GITHUB_TOKEN, + username="test-actor", + ) + + +@pytest.mark.parametrize("failure", ["failed_run", "untrusted_event", "failed_publish_step"]) +def test_failed_or_untrusted_publication_is_rejected( + monkeypatch: pytest.MonkeyPatch, failure: str +) -> None: + digest = registry_fixture(monkeypatch) + original = verifier._github_json + + def changed_github_json(url: str, token: str) -> dict[str, object]: + payload = original(url, token) + if "/jobs?" in url and failure == "failed_publish_step": + payload["jobs"][1]["conclusion"] = "failure" # type: ignore[index] + elif "/jobs?" not in url and failure == "failed_run": + payload["conclusion"] = "failure" + elif "/jobs?" not in url and failure == "untrusted_event": + payload["event"] = "pull_request" + return payload + + monkeypatch.setattr(verifier, "_github_json", changed_github_json) + with pytest.raises(verifier.VerificationError): + verifier.verify_publication( + publication_run_id="9", + source_commit=SOURCE_COMMIT, + image_digest=digest, + github_token=GITHUB_TOKEN, + username="test-actor", + ) + + +def test_validation_failure_does_not_log_secret_or_provider_error( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + def failed_request(*_: object, **__: object) -> None: + raise HTTPError("https://api.github.com", 403, GITHUB_TOKEN, {}, None) + + monkeypatch.setattr(verifier, "urlopen", failed_request) + output_file = tmp_path / "github-output.txt" + monkeypatch.setenv("GITHUB_TOKEN", GITHUB_TOKEN) + monkeypatch.setenv("GITHUB_ACTOR", "test-actor") + monkeypatch.setenv("GITHUB_OUTPUT", str(output_file)) + + assert verifier.main( + ["--publication-run-id", "9", "--source-commit", SOURCE_COMMIT, "--image-digest", IMAGE_DIGEST] + ) == 1 + captured = capsys.readouterr() + assert "EXISTING_IMAGE_VERIFICATION: FAIL" in captured.err + assert GITHUB_TOKEN not in captured.out + captured.err + assert "403" not in captured.out + captured.err + + +def test_workflow_existing_mode_is_manual_gated_and_does_not_publish() -> None: + workflow_file = Path( + os.environ.get( + "CODELENS_WORKFLOW_FILE", + Path(__file__).resolve().parents[1] / ".github/workflows/production.yml", + ) + ) + workflow = workflow_file.read_text() + assert "options: [deploy, deploy_existing, rollback]" in workflow + assert "if: github.event_name != 'workflow_dispatch' || inputs.operation == 'deploy' || inputs.operation == 'deploy_existing'" in workflow + assert "verify_existing:" in workflow + assert "if: github.event_name == 'workflow_dispatch' && inputs.operation == 'deploy_existing' && github.ref == 'refs/heads/main'" in workflow + assert "needs.verify_existing.result == 'success'" in workflow + assert "environment: production" in workflow + assert "inputs.operation == 'deploy_existing'" not in workflow.split(" publish:", 1)[1].split(" verify_existing:", 1)[0] + assert "github.event_name == 'workflow_dispatch'" in workflow + assert "github.ref == 'refs/heads/main'" in workflow + assert "vars.CODELENS_AUTO_DEPLOY == 'true'" in workflow + assert " rollback:\n if: github.event_name == 'workflow_dispatch' && inputs.operation == 'rollback'" in workflow