diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index f72a542be..c80cb5481 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -17,7 +17,8 @@ concurrency: # First-attempt PR validations share a generation key so a newer event # supersedes only another first attempt. Manual reruns add their stable # run_id and therefore cannot cancel, or be cancelled by, a newer PR event. - # Tag publication is unique per run and cancel-in-progress remains false. + # Tag runs remain unique here because release-set serialization is held by + # the reusable-workflow caller job for the full publication duration. group: docker-publish-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }}-${{ github.event_name == 'pull_request' && github.run_attempt == 1 && 'first-attempt' || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} @@ -180,200 +181,16 @@ jobs: sbom: false publish_images: - name: publish ${{ matrix.component }} image + name: publish release image set if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - runs-on: ubuntu-latest + concurrency: + group: Build and Publish Docker Images-publish-set-${{ github.repository }}-${{ github.ref }} + queue: max + cancel-in-progress: false permissions: contents: read packages: write - strategy: - fail-fast: false - matrix: - include: - - component: backend - image: ai_email_client-backend - dockerfile: Dockerfile - base_dockerfile: Dockerfile - context: . - build_args: | - BUILDKIT_INLINE_CACHE=1 - - component: naruon - image: naruon - dockerfile: Dockerfile - base_dockerfile: Dockerfile - context: . - build_args: | - BUILDKIT_INLINE_CACHE=1 - - component: frontend - image: ai_email_client-frontend - dockerfile: frontend/Dockerfile - base_dockerfile: frontend/Dockerfile - context: . - build_args: | - BUILDKIT_INLINE_CACHE=1 - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Read release version - id: version - run: | - VERSION="$(cat VERSION)" - TAG_VERSION="${GITHUB_REF_NAME#v}" - if [ "$TAG_VERSION" != "$VERSION" ]; then - printf 'Tag %s does not match VERSION %s\n' "$GITHUB_REF_NAME" "$VERSION" >&2 - exit 1 - fi - printf 'version=%s\n' "$VERSION" >> "$GITHUB_OUTPUT" - - - name: Set up QEMU - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - - name: Prepare OCI annotation values - id: oci - env: - BASE_DOCKERFILE: ${{ matrix.base_dockerfile }} - GIT_REF_NAME: ${{ github.ref_name }} - IMAGE_COMPONENT: ${{ matrix.component }} - IMAGE_NAME: ${{ matrix.image }} - REPOSITORY: ${{ github.repository }} - REVISION: ${{ github.sha }} - VERSION_VALUE: ${{ steps.version.outputs.version }} - run: | - version="${VERSION_VALUE:-$(cat VERSION)}" - created="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" - vendor="${REPOSITORY%%/*}" - base_reference="$(awk 'toupper($1) == "FROM" { print $2; exit }' "$BASE_DOCKERFILE")" - if ! printf '%s\n' "$base_reference" | grep -Eq '^[A-Za-z0-9._/-]+:[A-Za-z0-9._-]+@sha256:[0-9a-f]{64}$'; then - printf '::error file=%s,line=1::Expected an exact tagged sha256 base pin; found %s\n' "$BASE_DOCKERFILE" "$base_reference" - exit 1 - fi - base_digest="${base_reference##*@}" - base_repository="${base_reference%@*}" - case "$base_repository" in - */*) base_name="$base_reference" ;; - *) base_name="docker.io/library/$base_reference" ;; - esac - case "$IMAGE_COMPONENT" in - frontend) - title="naruon frontend" - description="Naruon Next.js frontend runtime image" - ;; - backend) - title="naruon backend" - description="Naruon FastAPI backend runtime image" - ;; - *) - title="naruon" - description="Naruon combined FastAPI and Next.js runtime image" - ;; - esac - { - printf 'created=%s\n' "$created" - printf 'authors=%s\n' "Seongho Bae" - printf 'url=https://github.com/%s/pkgs/container/%s\n' "$REPOSITORY" "$IMAGE_NAME" - printf 'documentation=https://github.com/%s#readme\n' "$REPOSITORY" - printf 'source=https://github.com/%s\n' "$REPOSITORY" - printf 'version=%s\n' "$version" - printf 'revision=%s\n' "$REVISION" - printf 'vendor=%s\n' "$vendor" - printf 'licenses=%s\n' "LicenseRef-Naruon-Proprietary" - printf 'ref_name=%s\n' "$GIT_REF_NAME" - printf 'title=%s\n' "$title" - printf 'description=%s\n' "$description" - printf 'base_digest=%s\n' "$base_digest" - printf 'base_name=%s\n' "$base_name" - } >> "$GITHUB_OUTPUT" - - - name: Log in to GHCR - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract Docker metadata - id: meta - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 - env: - DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index - with: - images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }} - tags: | - type=semver,pattern={{version}} - type=raw,value=${{ steps.version.outputs.version }} - type=raw,value=latest - labels: | - org.opencontainers.image.created=${{ steps.oci.outputs.created }} - org.opencontainers.image.authors=${{ steps.oci.outputs.authors }} - org.opencontainers.image.url=${{ steps.oci.outputs.url }} - org.opencontainers.image.documentation=${{ steps.oci.outputs.documentation }} - org.opencontainers.image.source=${{ steps.oci.outputs.source }} - org.opencontainers.image.version=${{ steps.oci.outputs.version }} - org.opencontainers.image.revision=${{ steps.oci.outputs.revision }} - org.opencontainers.image.vendor=${{ steps.oci.outputs.vendor }} - org.opencontainers.image.licenses=${{ steps.oci.outputs.licenses }} - org.opencontainers.image.ref.name=${{ steps.oci.outputs.ref_name }} - org.opencontainers.image.title=${{ steps.oci.outputs.title }} - org.opencontainers.image.description=${{ steps.oci.outputs.description }} - org.opencontainers.image.base.digest=${{ steps.oci.outputs.base_digest }} - org.opencontainers.image.base.name=${{ steps.oci.outputs.base_name }} - - - name: Build and publish Docker image - id: build - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - with: - context: ${{ matrix.context }} - file: ${{ matrix.dockerfile }} - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - annotations: ${{ steps.meta.outputs.annotations }} - build-args: | - ${{ matrix.build_args }} - OCI_IMAGE_CREATED=${{ steps.oci.outputs.created }} - OCI_IMAGE_AUTHORS=${{ steps.oci.outputs.authors }} - OCI_IMAGE_URL=${{ steps.oci.outputs.url }} - OCI_IMAGE_DOCUMENTATION=${{ steps.oci.outputs.documentation }} - OCI_IMAGE_SOURCE=${{ steps.oci.outputs.source }} - OCI_IMAGE_VERSION=${{ steps.oci.outputs.version }} - OCI_IMAGE_REVISION=${{ steps.oci.outputs.revision }} - OCI_IMAGE_VENDOR=${{ steps.oci.outputs.vendor }} - OCI_IMAGE_LICENSES=${{ steps.oci.outputs.licenses }} - OCI_IMAGE_REF_NAME=${{ steps.oci.outputs.ref_name }} - OCI_IMAGE_TITLE=${{ steps.oci.outputs.title }} - OCI_IMAGE_DESCRIPTION=${{ steps.oci.outputs.description }} - OCI_IMAGE_BASE_DIGEST=${{ steps.oci.outputs.base_digest }} - OCI_IMAGE_BASE_NAME=${{ steps.oci.outputs.base_name }} - provenance: true - sbom: true - - - name: Record image digest - env: - IMAGE_COMPONENT: ${{ matrix.component }} - IMAGE_DIGEST: ${{ steps.build.outputs.digest }} - IMAGE_NAME: ${{ matrix.image }} - IMAGE_REGISTRY: ${{ env.REGISTRY }} - IMAGE_VERSION: ${{ steps.version.outputs.version }} - REPO_OWNER: ${{ github.repository_owner }} - run: | - { - printf '### %s image\n' "$IMAGE_COMPONENT" - printf -- '- Image: %s/%s/%s\n' "$IMAGE_REGISTRY" "$REPO_OWNER" "$IMAGE_NAME" - printf -- '- Version: %s\n' "$IMAGE_VERSION" - printf -- '- Digest: %s\n' "$IMAGE_DIGEST" - } >> "$GITHUB_STEP_SUMMARY" + uses: ./.github/workflows/docker-release-images.yml deploy_preflight: name: Detect AKS deploy configuration diff --git a/.github/workflows/docker-release-images.yml b/.github/workflows/docker-release-images.yml new file mode 100644 index 000000000..4639d3e1e --- /dev/null +++ b/.github/workflows/docker-release-images.yml @@ -0,0 +1,211 @@ +name: Publish Docker Release Images + +on: + workflow_call: + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + publish_images: + name: publish ${{ matrix.component }} image + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - component: backend + image: ai_email_client-backend + dockerfile: Dockerfile + base_dockerfile: Dockerfile + context: . + build_args: | + BUILDKIT_INLINE_CACHE=1 + - component: naruon + image: naruon + dockerfile: Dockerfile + base_dockerfile: Dockerfile + context: . + build_args: | + BUILDKIT_INLINE_CACHE=1 + - component: frontend + image: ai_email_client-frontend + dockerfile: frontend/Dockerfile + base_dockerfile: frontend/Dockerfile + context: . + build_args: | + BUILDKIT_INLINE_CACHE=1 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Read release version + id: version + run: | + VERSION="$(cat VERSION)" + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + printf 'VERSION %s must be a stable X.Y.Z release before publishing latest\n' "$VERSION" >&2 + exit 1 + fi + TAG_VERSION="${GITHUB_REF_NAME#v}" + if [ "$TAG_VERSION" != "$VERSION" ]; then + printf 'Tag %s does not match VERSION %s\n' "$GITHUB_REF_NAME" "$VERSION" >&2 + exit 1 + fi + printf 'version=%s\n' "$VERSION" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Prepare OCI annotation values + id: oci + env: + BASE_DOCKERFILE: ${{ matrix.base_dockerfile }} + GIT_REF_NAME: ${{ github.ref_name }} + IMAGE_COMPONENT: ${{ matrix.component }} + IMAGE_NAME: ${{ matrix.image }} + REPOSITORY: ${{ github.repository }} + REVISION: ${{ github.sha }} + VERSION_VALUE: ${{ steps.version.outputs.version }} + run: | + version="${VERSION_VALUE:-$(cat VERSION)}" + created="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" + vendor="${REPOSITORY%%/*}" + base_reference="$(awk 'toupper($1) == "FROM" { print $2; exit }' "$BASE_DOCKERFILE")" + if ! printf '%s\n' "$base_reference" | grep -Eq '^[A-Za-z0-9._/-]+:[A-Za-z0-9._-]+@sha256:[0-9a-f]{64}$'; then + printf '::error file=%s,line=1::Expected an exact tagged sha256 base pin; found %s\n' "$BASE_DOCKERFILE" "$base_reference" + exit 1 + fi + base_digest="${base_reference##*@}" + base_repository="${base_reference%@*}" + case "$base_repository" in + */*) base_name="$base_reference" ;; + *) base_name="docker.io/library/$base_reference" ;; + esac + case "$IMAGE_COMPONENT" in + frontend) + title="naruon frontend" + description="Naruon Next.js frontend runtime image" + ;; + backend) + title="naruon backend" + description="Naruon FastAPI backend runtime image" + ;; + *) + title="naruon" + description="Naruon combined FastAPI and Next.js runtime image" + ;; + esac + { + printf 'created=%s\n' "$created" + printf 'authors=%s\n' "Seongho Bae" + printf 'url=https://github.com/%s/pkgs/container/%s\n' "$REPOSITORY" "$IMAGE_NAME" + printf 'documentation=https://github.com/%s#readme\n' "$REPOSITORY" + printf 'source=https://github.com/%s\n' "$REPOSITORY" + printf 'version=%s\n' "$version" + printf 'revision=%s\n' "$REVISION" + printf 'vendor=%s\n' "$vendor" + printf 'licenses=%s\n' "LicenseRef-Naruon-Proprietary" + printf 'ref_name=%s\n' "$GIT_REF_NAME" + printf 'title=%s\n' "$title" + printf 'description=%s\n' "$description" + printf 'base_digest=%s\n' "$base_digest" + printf 'base_name=%s\n' "$base_name" + } >> "$GITHUB_OUTPUT" + + - name: Log in to GHCR + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + env: + DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index + with: + images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }} + flavor: | + latest=false + tags: | + type=semver,pattern={{version}} + type=raw,value=${{ steps.version.outputs.version }} + type=raw,value=latest + labels: | + org.opencontainers.image.created=${{ steps.oci.outputs.created }} + org.opencontainers.image.authors=${{ steps.oci.outputs.authors }} + org.opencontainers.image.url=${{ steps.oci.outputs.url }} + org.opencontainers.image.documentation=${{ steps.oci.outputs.documentation }} + org.opencontainers.image.source=${{ steps.oci.outputs.source }} + org.opencontainers.image.version=${{ steps.oci.outputs.version }} + org.opencontainers.image.revision=${{ steps.oci.outputs.revision }} + org.opencontainers.image.vendor=${{ steps.oci.outputs.vendor }} + org.opencontainers.image.licenses=${{ steps.oci.outputs.licenses }} + org.opencontainers.image.ref.name=${{ steps.oci.outputs.ref_name }} + org.opencontainers.image.title=${{ steps.oci.outputs.title }} + org.opencontainers.image.description=${{ steps.oci.outputs.description }} + org.opencontainers.image.base.digest=${{ steps.oci.outputs.base_digest }} + org.opencontainers.image.base.name=${{ steps.oci.outputs.base_name }} + + - name: Build and publish Docker image + id: build + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + annotations: ${{ steps.meta.outputs.annotations }} + build-args: | + ${{ matrix.build_args }} + OCI_IMAGE_CREATED=${{ steps.oci.outputs.created }} + OCI_IMAGE_AUTHORS=${{ steps.oci.outputs.authors }} + OCI_IMAGE_URL=${{ steps.oci.outputs.url }} + OCI_IMAGE_DOCUMENTATION=${{ steps.oci.outputs.documentation }} + OCI_IMAGE_SOURCE=${{ steps.oci.outputs.source }} + OCI_IMAGE_VERSION=${{ steps.oci.outputs.version }} + OCI_IMAGE_REVISION=${{ steps.oci.outputs.revision }} + OCI_IMAGE_VENDOR=${{ steps.oci.outputs.vendor }} + OCI_IMAGE_LICENSES=${{ steps.oci.outputs.licenses }} + OCI_IMAGE_REF_NAME=${{ steps.oci.outputs.ref_name }} + OCI_IMAGE_TITLE=${{ steps.oci.outputs.title }} + OCI_IMAGE_DESCRIPTION=${{ steps.oci.outputs.description }} + OCI_IMAGE_BASE_DIGEST=${{ steps.oci.outputs.base_digest }} + OCI_IMAGE_BASE_NAME=${{ steps.oci.outputs.base_name }} + provenance: true + sbom: true + + - name: Record image digest + env: + IMAGE_COMPONENT: ${{ matrix.component }} + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + IMAGE_NAME: ${{ matrix.image }} + IMAGE_REGISTRY: ${{ env.REGISTRY }} + IMAGE_VERSION: ${{ steps.version.outputs.version }} + REPO_OWNER: ${{ github.repository_owner }} + run: | + { + printf '### %s image\n' "$IMAGE_COMPONENT" + printf -- '- Image: %s/%s/%s\n' "$IMAGE_REGISTRY" "$REPO_OWNER" "$IMAGE_NAME" + printf -- '- Version: %s\n' "$IMAGE_VERSION" + printf -- '- Digest: %s\n' "$IMAGE_DIGEST" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/backend/tests/test_docker_workflow_concurrency.py b/backend/tests/test_docker_workflow_concurrency.py index d90ee5786..e401f6b81 100644 --- a/backend/tests/test_docker_workflow_concurrency.py +++ b/backend/tests/test_docker_workflow_concurrency.py @@ -24,5 +24,78 @@ def test_docker_pr_concurrency_isolates_reruns_from_first_attempts() -> None: ) assert expected_group in header - assert bare_group not in header.splitlines() + assert bare_group not in {line.strip() for line in header.splitlines()} assert "cancel-in-progress: ${{ github.event_name == 'pull_request' }}" in header + + +def test_docker_release_publication_queues_each_component_per_ref() -> None: + """Keep all image components inside the reusable release boundary.""" + release_workflow = ( + REPO_ROOT / ".github/workflows/docker-release-images.yml" + ).read_text(encoding="utf-8") + publish_section = release_workflow.split("jobs:\n", 1)[1] + + assert "workflow_call:" in release_workflow + assert "matrix.component" in publish_section + assert "push: true" in publish_section + assert "sbom: true" in publish_section + assert "provenance: true" in publish_section + assert "packages: write" in release_workflow + assert "password: ${{ secrets.GITHUB_TOKEN }}" in publish_section + + +def test_docker_release_publication_serializes_whole_image_set_per_ref() -> None: + """Hold one same-ref release lock until every component publication completes.""" + workflow = (REPO_ROOT / ".github/workflows/docker-publish.yml").read_text( + encoding="utf-8" + ) + publish_section = workflow.split(" publish_images:\n", 1)[1].split( + "\n deploy_preflight:", 1 + )[0] + expected_group = ( + "group: Build and Publish Docker Images-publish-set-" + "${{ github.repository }}-${{ github.ref }}" + ) + + assert "uses: ./.github/workflows/docker-release-images.yml" in publish_section + assert expected_group in publish_section + assert "queue: max" in publish_section + assert "cancel-in-progress: false" in publish_section + assert "packages: write" in publish_section + assert "matrix.component" not in publish_section + + +def test_release_latest_tag_is_stable_version_only() -> None: + """Fail closed before a prerelease VERSION can mutate the latest image tag.""" + release_workflow = ( + REPO_ROOT / ".github/workflows/docker-release-images.yml" + ).read_text(encoding="utf-8") + + assert 'if ! [[ "$VERSION" =~ ^[0-9]+\\.[0-9]+\\.[0-9]+$ ]]; then' in ( + release_workflow + ) + assert "must be a stable X.Y.Z release" in release_workflow + assert "flavor: |\n latest=false" in release_workflow + assert "type=raw,value=latest" in release_workflow + + +def test_release_serialization_decision_and_operability_docs_match_active_pr() -> None: + """Keep release-set semantics documented without claiming protected acceptance.""" + adr = ( + REPO_ROOT / "docs/adr/0005-whole-release-publication-serialization.md" + ).read_text(encoding="utf-8") + adr_index = (REPO_ROOT / "docs/adr/README.md").read_text(encoding="utf-8") + operations = ( + REPO_ROOT / "docs/operations/release-deployment-architecture.md" + ).read_text(encoding="utf-8") + + assert "**Status:** Proposed" in adr + assert "PR #1621; not protected-branch authority" in adr + assert "queue: max" in adr + assert "cancel-in-progress: false" in adr + assert "docker-release-images.yml" in adr + assert "ADR-0005" in adr_index + assert "PR #1621 `ACTIVE-PR`; no protected-release acceptance yet" in adr_index + assert "active-PR evidence, not protected-branch authority" in operations + assert "docker-release-images.yml" in operations + assert "A failed image publication blocks deployment" in operations diff --git a/backend/tests/test_release_governance.py b/backend/tests/test_release_governance.py index 3b2eb610c..15fd37ed8 100644 --- a/backend/tests/test_release_governance.py +++ b/backend/tests/test_release_governance.py @@ -105,18 +105,20 @@ def test_release_version_sources_are_synchronized() -> None: def test_container_images_cover_all_oci_predefined_image_annotations() -> None: root_dockerfile = read_repo_text("Dockerfile") frontend_dockerfile = read_repo_text("frontend/Dockerfile") - docker_publish_workflow = read_repo_text(".github/workflows/docker-publish.yml") + docker_release_workflow = read_repo_text( + ".github/workflows/docker-release-images.yml" + ) for annotation_key in OCI_PREDEFINED_IMAGE_ANNOTATION_KEYS: assert annotation_key in root_dockerfile assert annotation_key in frontend_dockerfile - assert annotation_key in docker_publish_workflow + assert annotation_key in docker_release_workflow assert ( - "DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index" in docker_publish_workflow + "DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index" in docker_release_workflow ) assert ( - "annotations: ${{ steps.meta.outputs.annotations }}" in docker_publish_workflow + "annotations: ${{ steps.meta.outputs.annotations }}" in docker_release_workflow ) assert_oci_metadata_matches_first_base(root_dockerfile) assert_oci_metadata_matches_first_base(frontend_dockerfile) @@ -385,6 +387,7 @@ def test_stepsecurity_remediation_adds_pinned_audit_hardening() -> None: ".github/workflows/app-ci.yml", ".github/workflows/bandit.yml", ".github/workflows/docker-publish.yml", + ".github/workflows/docker-release-images.yml", ".github/workflows/pr-governance.yml", ] @@ -674,32 +677,35 @@ def test_docker_publish_validates_pr_images_and_publishes_semver_images_only_on_ None ): workflow = read_repo_text(".github/workflows/docker-publish.yml") + release_workflow = read_repo_text(".github/workflows/docker-release-images.yml") + combined_workflows = f"{workflow}\n{release_workflow}" assert "pull_request:" in workflow assert "push:" in workflow - assert "FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true" in workflow + assert "workflow_call:" in release_workflow + assert "FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true" in combined_workflows assert ( - workflow.count( + combined_workflows.count( "docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0" ) == 2 ) assert ( - workflow.count( + combined_workflows.count( "docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0" ) == 2 ) assert ( "docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0" - in workflow + in release_workflow ) assert ( "docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0" - in workflow + in release_workflow ) assert ( - workflow.count( + combined_workflows.count( "docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0" ) == 2 @@ -711,26 +717,27 @@ def test_docker_publish_validates_pr_images_and_publishes_semver_images_only_on_ assert "tags:" in push_block assert "branches:" not in push_block assert "develop" in pull_request_block - assert "ai_email_client-backend" in workflow - assert "ai_email_client-frontend" in workflow - assert workflow.count("image: naruon") == 2 + assert "ai_email_client-backend" in combined_workflows + assert "ai_email_client-frontend" in combined_workflows + assert combined_workflows.count("image: naruon") == 2 assert "push: false" in workflow - assert "push: true" in workflow - assert workflow.count("base_dockerfile: Dockerfile") == 4 - assert workflow.count("base_dockerfile: frontend/Dockerfile") == 2 - assert workflow.count('base_digest="${base_reference##*@}"') == 2 - assert workflow.count('base_name="docker.io/library/$base_reference"') == 2 + assert "push: true" in release_workflow + assert combined_workflows.count("base_dockerfile: Dockerfile") == 4 + assert combined_workflows.count("base_dockerfile: frontend/Dockerfile") == 2 + assert combined_workflows.count('base_digest="${base_reference##*@}"') == 2 + assert combined_workflows.count('base_name="docker.io/library/$base_reference"') == 2 assert "Resolve pinned Ollama base manifest" in workflow assert "docker buildx imagetools inspect" in workflow assert "Platform:[[:space:]]+${platform}[[:space:]]*$" in workflow assert "Pinned Ollama manifest is missing %s" in workflow assert "linux/amd64 linux/arm64" in workflow - assert "sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061" not in workflow - assert "sha256:191ef878ecb351d68b78219593de18bd8942afd59af59f29960dc4b24805a3f1" not in workflow + assert "sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061" not in combined_workflows + assert "sha256:191ef878ecb351d68b78219593de18bd8942afd59af59f29960dc4b24805a3f1" not in combined_workflows assert "sbom: false" in workflow - assert workflow.count("sbom: true") == 1 - assert "type=semver" in workflow - assert "type=ref,event=branch" not in workflow + assert release_workflow.count("sbom: true") == 1 + assert "type=semver" in release_workflow + assert "type=ref,event=branch" not in release_workflow + assert "uses: ./.github/workflows/docker-release-images.yml" in workflow assert "deploy_preflight:" in workflow assert "AKS_KUBECONFIG_CONTENT: ${{ secrets.AKS_KUBECONFIG }}" in workflow assert "configured=false" in workflow diff --git a/docs/adr/0005-whole-release-publication-serialization.md b/docs/adr/0005-whole-release-publication-serialization.md new file mode 100644 index 000000000..eb2a700d4 --- /dev/null +++ b/docs/adr/0005-whole-release-publication-serialization.md @@ -0,0 +1,93 @@ +# ADR-0005: Serialize Docker release image sets at the reusable-workflow caller + +- **Status:** Proposed +- **Date:** 2026-09-09 +- **Owner:** Naruon release publication +- **Implementation:** PR #1621; not protected-branch authority until normally merged + +## Problem + +Naruon publishes backend, combined `naruon`, and frontend container images from one version tag. Per-component concurrency prevents two backend jobs, two frontend jobs, or two combined-image jobs for the same ref from evicting one another, but it does not make the three-image release a serialized set. Two workflow runs for the same tag/ref can otherwise interleave their component publications. A downstream deployment that depends only on completion of its own matrix can then run while another same-ref publication is also mutating release tags. + +The release boundary therefore needs one lock whose lifetime covers all three image publications while preserving parallelism inside a single release. The release workflow also writes the mutable `latest` tag, so a non-stable `VERSION` must fail before metadata or publication instead of moving `latest` to a prerelease image. + +## Constraints + +- PR image validation keeps #1592's first-attempt cancellation identity; manual reruns must not be cancelled by a newer first-attempt event. +- Tag publication must never cancel an in-progress release for the same repository/ref. +- A queued same-ref release must not replace an earlier pending release merely because it arrived later. +- Backend, combined, and frontend images should still build in parallel inside one release. +- Naruon's governed `VERSION` contract is a stable numeric `X.Y.Z`; prerelease/build suffixes are not release-publication inputs for this path. +- Automatic metadata-action `latest` generation must be disabled so the workflow has one explicit, reviewable `latest` source after stable-version validation. +- Existing tag/`VERSION` equality checks, multi-architecture builds, OCI metadata, SBOM, provenance, digest evidence, and AKS deployment ordering must remain intact. +- The design must use supported GitHub Actions primitives rather than a repository-local lock service or mutable external coordination record. + +## Decision + +Keep `.github/workflows/docker-publish.yml` as the event-facing caller. PR image validation remains there. Move tag image publication into `.github/workflows/docker-release-images.yml` as a local reusable workflow invoked with `workflow_call`. + +The caller `publish_images` job holds one concurrency group for the entire called workflow: + +```yaml +concurrency: + group: Build and Publish Docker Images-publish-set-${{ github.repository }}-${{ github.ref }} + queue: max + cancel-in-progress: false +``` + +The called workflow owns the backend/naruon/frontend matrix and therefore keeps those three builds parallel after the caller acquires the release-set lock. `deploy_preflight` continues to need the caller job, so deployment remains downstream of completion of every matrix child in the called workflow. + +Before it compares the tag with `VERSION`, the called workflow requires `VERSION` to match stable `X.Y.Z`. Any prerelease/build suffix fails closed before registry login or metadata generation. `docker/metadata-action` uses `flavor.latest=false`; the workflow then declares one explicit raw `latest` tag, which can only execute after the stable-version guard passes. This matches the existing repository version-governance test instead of creating a second prerelease policy in release YAML. + +GitHub's current Actions contract documents `queue: max` for workflow/job concurrency, with up to 100 pending jobs or runs in one concurrency group, and disallows combining it with `cancel-in-progress: true`. GitHub also documents `jobs..concurrency` and `jobs..permissions` as supported keywords for jobs that call reusable workflows. + +Docker metadata-action v6.2.0 documents `flavor.latest` as the control for automatic latest handling and shows that prerelease tag events can otherwise participate in latest-tag generation depending on tag strategy. Naruon therefore disables automatic handling and keeps its stable-only publication invariant explicit. + +## Alternatives considered + +### Keep per-component release groups + +Rejected as the complete solution. It prevents a backend publication from evicting another backend publication, but release A and release B can still interleave different components. That is component safety, not release-set serialization. + +### Put one queued group on the entire mixed PR/tag workflow + +Rejected. PR validation intentionally cancels superseded first attempts while tag publication must queue without cancellation. `queue: max` and `cancel-in-progress: true` are incompatible in one concurrency mapping, and weakening #1592's PR-rerun identity would reintroduce a repaired CI invariant. + +### Serialize all three image builds inside one non-matrix job + +Rejected. It would establish a lock but unnecessarily removes safe component parallelism and lengthens release publication without improving the release-set invariant. + +### Allow prereleases but conditionally omit `latest` + +Rejected for this release path. The repository's governed `VERSION` contract already requires stable `X.Y.Z`. Permitting a second version grammar only in Docker publication would create divergent release authority. A future prerelease channel requires its own explicit version/tag/channel contract rather than an implicit exception here. + +### Use an environment or external lock service + +Rejected for this boundary. Environments introduce deployment/protection semantics not required for image publication, while an external lock adds mutable coordination state and another availability/security dependency when GitHub Actions already provides the required repository/ref queue primitive. + +## Consequences + +- Same-repository/same-ref release executions are serialized at the release-set boundary. +- Backend, combined, and frontend publications remain parallel within one admitted release. +- Up to 100 later same-group jobs/runs may wait; requests beyond the platform bound can be rejected and must not be described as guaranteed delivery. +- Different tag refs remain independent release groups and can run concurrently. +- A prerelease/build-suffixed `VERSION` fails before publication and cannot move `latest` through this workflow. +- Release implementation is split across an event-facing caller and a reusable publication workflow, so governance tests and operations documentation must read both files rather than assuming one workflow contains both PR validation and release publication. +- The decision is **Proposed** until the implementation is normally integrated into protected `develop` and exact-head workflow/review evidence is complete. A predecessor run or a source-only test is not release acceptance. + +## Verification and traceability + +- Whole-set reality RED: `86ea64021487c6b2c9897832d5645db7fbe3b7d7` — requires one whole-set caller lock and a reusable release boundary. +- Whole-set source repair: `c8b565ec7b38cd84bf980be9a6672fafe3bd126c` — introduces the caller lock and reusable release matrix. +- Governance contract repair: `e4ab2abd5e1108fbf2acfdd06e6ce42f61d30495` — moves release-only assertions to the reusable workflow and keeps caller+called composition checks. +- Release-channel RED: `d5b1e09d9c65174981734deb58780a67f6d40c27` — makes stable-only `latest` publication executable and repairs the previously vacuous bare-group assertion. +- Release-channel source repair: `0d45b5324e5bd8b708212a9e729460a886230933` — rejects non-`X.Y.Z` `VERSION` values and disables metadata-action automatic latest generation. +- Focused regression: `backend/tests/test_docker_workflow_concurrency.py`. +- Broader release contract: `backend/tests/test_release_governance.py`. +- Operability description: `docs/operations/release-deployment-architecture.md`. +- Primary platform authority: GitHub, *Workflow syntax for GitHub Actions*, `jobs..concurrency`; GitHub, *Reusing workflow configurations*, supported keywords for reusable-workflow caller jobs. Accessed 2026-09-09. +- Publication metadata authority: Docker, *metadata-action v6.2.0 README*, flavor/latest and semver guidance at exact action commit `dc802804100637a589fabce1cb79ff13a1411302`. Accessed 2026-09-09. + +## Follow-up + +After #1592 and the applicable protected-base security prerequisite land normally, restack/retarget #1621 without dropping this boundary. Require one unchanged exact head with the then-live repository and central required checks terminal-success plus qualifying independent review. The first real tag publication after protected integration must retain digest, SBOM, provenance, and rollback evidence; failure of any component keeps deployment blocked. \ No newline at end of file diff --git a/docs/adr/README.md b/docs/adr/README.md index 4d461fff6..b957996fd 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -13,6 +13,7 @@ govern implementation. | [ADR-0002](0002-fitted-topic-artifact-consumption.md) | Conditionally consume only a versioned fitted topic artifact through a fail-closed adapter | Proposed | Target `PLANNED`; runtime `BLOCKED-UPSTREAM` | | [ADR-0003](0003-separate-topic-measurement-from-agenda-generation.md) | Keep statistical measurement separate from agenda generation | Proposed | Target and future capability `PLANNED`; no implementation authorization | | [ADR-0004](0004-status-weighted-calendar-conflicts.md) | Evaluate CalDAV VEVENT overlaps by occupying status; cancelled does not occupy | Accepted | `ACCEPTED-NARUON-POLICY`; advisory evaluate API only | +| [ADR-0005](0005-whole-release-publication-serialization.md) | Serialize each same-ref Docker release as one image set while retaining component parallelism inside the set | Proposed | PR #1621 `ACTIVE-PR`; no protected-release acceptance yet | The complete topic-intelligence requirements, architecture, contract, UML, conceptual ERD, security, test, and operability graph is indexed at @@ -22,7 +23,7 @@ is the single cross-document list for the planned adapter profile. ## Change rule -Create or update an ADR when a Naruon change adopts or declines an external service contract, introduces a new scientific/statistical inference contract, changes persistence or tenant authority, changes model/credential trust boundaries, or replaces a fail-closed product capability with a different production dependency. A Naruon ADR records Naruon's decision only; it cannot assign authority to, or accept a decision for, another service. +Create or update an ADR when a Naruon change adopts or declines an external service contract, introduces a new scientific/statistical inference contract, changes persistence or tenant authority, changes model/credential trust boundaries, or replaces a fail-closed product capability with a different production dependency. Cross-cutting release, provenance, or deployment ordering decisions that can change the meaning of a published version also require an ADR. A Naruon ADR records Naruon's decision only; it cannot assign authority to, or accept a decision for, another service. Every implementing PR must keep the corresponding source, tests, doctoring, architecture/operability contract, and CHANGELOG maturity truthful. An active PR, diff --git a/docs/operations/release-deployment-architecture.md b/docs/operations/release-deployment-architecture.md index 6feffcb61..39687cca5 100644 --- a/docs/operations/release-deployment-architecture.md +++ b/docs/operations/release-deployment-architecture.md @@ -3,31 +3,88 @@ ## 확인된 사실 / Confirmed - `ARCHITECTURE.md` defines the current runtime as Next.js frontend → FastAPI - backend → PostgreSQL with pgvector, with OpenAI and SMTP used only when - configured. + backend → PostgreSQL with pgvector, with the protected-document LLM provider + description tracked separately as governance drift until its canonical repair + lands. - `docker-compose.yml` is the local development stack for db/backend/frontend. - `.github/workflows/app-ci.yml` runs backend pytest and frontend test/lint/build checks on pull requests without release-branch push duplication. -- `.github/workflows/docker-publish.yml` validates backend/frontend Docker images - for PRs and publishes GHCR images only from `v*` tags whose value matches - `VERSION`. +- `.github/workflows/docker-publish.yml` is the event-facing Docker workflow. It + validates backend/frontend/combined images on supported PR bases and accepts + `v*` tag events only for release publication. +- PR #1621 proposes a release boundary in which the tag caller invokes + `.github/workflows/docker-release-images.yml`; that reusable workflow owns the + backend/naruon/frontend publication matrix, exact tag/`VERSION` equality check, + stable `X.Y.Z` version validation, OCI metadata, multi-architecture GHCR + publishing, SBOM, provenance, and digest evidence. This is **active-PR + evidence, not protected-branch authority** until the stack is normally merged. +- Under the #1621 proposal, the caller `publish_images` job holds one + repository+ref concurrency group with `queue: max` and + `cancel-in-progress: false` for the full reusable-workflow execution. This + serializes same-ref release image sets while leaving the three component builds + parallel inside one admitted release. See + [`ADR-0005`](../adr/0005-whole-release-publication-serialization.md). +- The release workflow disables docker/metadata-action automatic latest handling + and declares `latest` explicitly only after `VERSION` has passed the stable + `X.Y.Z` guard. A prerelease/build-suffixed `VERSION` fails before registry + publication rather than moving `latest` to an unstable image. +- `deploy_preflight` and `deploy_to_aks` depend on completion of the release + publication caller, so an AKS deployment cannot start until the whole called + image matrix succeeds. - `docker-compose.live-e2e.yml` is the live E2E stack: it uses pre-built images, seeds deterministic email data, scales backend replicas, and exposes the stack through nginx at `127.0.0.1:18080`. +## 플랫폼 제약 / Platform constraints + +GitHub Actions currently permits `queue: max` on concurrency groups, allowing up +to 100 pending jobs or workflow runs in one group; additional members can be +rejected when that bound is reached. `queue: max` cannot be combined with +`cancel-in-progress: true`. Reusable-workflow caller jobs support `concurrency` +and `permissions`. These platform facts are part of the release design rather +than an application-level guarantee: Naruon does not promise unbounded release +queueing. + +Docker metadata-action v6.2.0 uses `flavor.latest` to control automatic latest +handling. Naruon sets `latest=false` there and keeps one explicit raw `latest` +tag after the stable-version guard, avoiding a second implicit latest source. + +Primary references, accessed 2026-09-09: + +- GitHub. *Workflow syntax for GitHub Actions* — `jobs..concurrency`. +- GitHub. *Reusing workflow configurations* — supported keywords for jobs that + call reusable workflows. +- Docker. *metadata-action v6.2.0 README* — flavor/latest and semver guidance at + exact action commit `dc802804100637a589fabce1cb79ff13a1411302`. + ## 가설 / Hypothesis -- The first release candidate should use tag `v0.1.0`, then verify backend and - frontend GHCR manifests for `linux/amd64` and `linux/arm64` before production - promotion. +- The next protected release candidate should verify backend, combined `naruon`, + and frontend GHCR manifests for `linux/amd64` and `linux/arm64` before + production promotion. - Deployment promotion should use image digests rather than mutable tags after the tag workflow produces digest evidence. +- The first tag execution after ADR-0005 reaches protected `develop` is required + release evidence for the whole-set serialization path; source regression tests + or predecessor PR checks do not prove an actual publication run. ## 운영 절차 / Operating path -1. Build images locally or in CI from the release branch. -2. Run backend pytest and frontend test/lint/build checks. -3. Run live Docker E2E against built images. -4. Push `v$(cat VERSION)` only after checks and robot-review evidence are current. -5. Record GHCR digest, manifest platforms, and live E2E evidence in the PR or - release evidence comment. +1. Build images locally or in CI from the release branch and run the full + repository security/test gates on one exact head. +2. Run live Docker E2E against the candidate images and preserve the evidence. +3. Confirm that `VERSION` is stable numeric `X.Y.Z`, the planned tag is exactly + `v$(cat VERSION)`, CHANGELOG and required review state are current, and SBOM / + provenance plus rollback expectations are recorded. Do not use this path for + prerelease/build-suffixed versions. +4. Push `v$(cat VERSION)` only after the protected source and review evidence are + current. Do not recreate or move a release tag to manufacture a rerun. +5. Let the release-set caller acquire the same-ref publication lock; backend, + combined, and frontend image jobs may run in parallel only inside that one + admitted set. +6. Require all component publications to succeed before deployment preflight. + A failed image publication blocks deployment rather than publishing a clean + release claim for a partial set. +7. Record GHCR digests, manifest platforms, SBOM/provenance receipts, live E2E + evidence, GitHub Release/tag identity, and rollback instructions in the + release evidence record.