From 40c6da01e229bc845b6d9c70430c79bafd87e577 Mon Sep 17 00:00:00 2001 From: Cihan Alagoz Date: Sun, 6 Sep 2026 15:36:27 +0300 Subject: [PATCH 1/2] fix(release): verify managed Community artifacts and compatibility --- .github/workflows/build-community.yml | 152 ++++++------------ .github/workflows/release-workflow-checks.yml | 22 +++ ci/notify-release-crm.sh | 48 ++++++ ci/registry-digest.sh | 23 +++ ci/release-workflows.test.mjs | 113 +++++++++++++ ci/resolve-image.sh | 29 ++++ ci/wait-community-compat.sh | 15 ++ 7 files changed, 301 insertions(+), 101 deletions(-) create mode 100644 .github/workflows/release-workflow-checks.yml create mode 100644 ci/notify-release-crm.sh create mode 100644 ci/registry-digest.sh create mode 100644 ci/release-workflows.test.mjs create mode 100644 ci/resolve-image.sh create mode 100644 ci/wait-community-compat.sh diff --git a/.github/workflows/build-community.yml b/.github/workflows/build-community.yml index 8d0bf6b8..0f63b850 100644 --- a/.github/workflows/build-community.yml +++ b/.github/workflows/build-community.yml @@ -9,16 +9,36 @@ permissions: contents: read packages: write +concurrency: + group: console-community-${{ github.ref_name }} + cancel-in-progress: false + jobs: build: runs-on: ubuntu-latest + environment: production + timeout-minutes: 45 outputs: - image_digest: ${{ steps.image.outputs.digest }} + image_digest: ${{ steps.identity.outputs.digest }} commit_sha: ${{ steps.vars.outputs.commit_sha }} steps: - name: Checkout uses: actions/checkout@v4 + - name: Capture release identity + id: vars + run: echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Verify managed release with CRM + env: + WEBHOOK_URL: ${{ secrets.RELEASE_WEBHOOK_URL }} + WEBHOOK_SECRET: ${{ secrets.RELEASE_WEBHOOK_SECRET }} + COMMIT_SHA: ${{ steps.vars.outputs.commit_sha }} + RELEASE_VERSION: ${{ github.ref_name }} + RELEASE_STATUS: pending + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: bash ci/notify-release-crm.sh + - name: Login to GHCR uses: docker/login-action@v3 with: @@ -29,8 +49,16 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Resolve existing image + id: existing + env: + IMAGE_REF: ghcr.io/cognipeer/console:${{ github.ref_name }} + SOURCE_SHA_REF: ghcr.io/cognipeer/console:sha-${{ steps.vars.outputs.commit_sha }} + run: bash ci/resolve-image.sh + - name: Build & Push to GHCR id: image + if: steps.existing.outputs.reuse != 'true' uses: docker/build-push-action@v6 with: context: . @@ -38,12 +66,20 @@ jobs: push: true cache-from: type=gha cache-to: type=gha,mode=max - tags: ghcr.io/cognipeer/console:${{ github.ref_name }} + tags: | + ghcr.io/cognipeer/console:${{ github.ref_name }} + ghcr.io/cognipeer/console:sha-${{ steps.vars.outputs.commit_sha }} - - name: Capture release identity - id: vars - if: always() - run: echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Capture image identity + id: identity + env: + EXISTING_DIGEST: ${{ steps.existing.outputs.digest }} + BUILT_DIGEST: ${{ steps.image.outputs.digest }} + run: | + set -euo pipefail + DIGEST="${EXISTING_DIGEST:-${BUILT_DIGEST:-}}" + [[ "${DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "::error::Community image digest is missing."; exit 1; } + echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" sync-console-ee: runs-on: ubuntu-latest @@ -113,49 +149,7 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} COMMUNITY_TAG: ${{ github.ref_name }} CONSOLE_EE_REPOSITORY: Cognipeer/console-ee - run: | - set -euo pipefail - TITLE="chore(compat): pin ${COMMUNITY_TAG}" - MAX_ATTEMPTS=180 - - for ((attempt=1; attempt<=MAX_ATTEMPTS; attempt++)); do - PR_JSON=$(gh api \ - --method GET "repos/${CONSOLE_EE_REPOSITORY}/pulls" \ - -f state=all \ - -f head="Cognipeer:automation/community-compat" \ - -f base=main \ - -f per_page=100 \ - | jq --arg title "${TITLE}" \ - '[.[] | select(.title == $title)] | sort_by(.number) | last') - - if [[ "${PR_JSON}" != "null" ]]; then - PR_STATE=$(jq -r .state <<<"${PR_JSON}") - MERGED_AT=$(jq -r '.merged_at // ""' <<<"${PR_JSON}") - if [[ "${PR_STATE}" == "closed" && -z "${MERGED_AT}" ]]; then - echo "::error::Compatibility PR for ${COMMUNITY_TAG} was closed without merging." - exit 1 - fi - - if [[ -n "${MERGED_AT}" ]]; then - COMMUNITY_REF=$(gh api \ - "repos/${CONSOLE_EE_REPOSITORY}/contents/COMPAT.json?ref=main" \ - --jq .content \ - | tr -d '\n' \ - | base64 --decode \ - | jq -r .communityRef) - if [[ "${COMMUNITY_REF}" == "${COMMUNITY_TAG}" ]]; then - exit 0 - fi - fi - fi - - if [[ "${attempt}" -lt "${MAX_ATTEMPTS}" ]]; then - sleep 10 - fi - done - - echo "::error::Console Enterprise compatibility merge was not observed for ${COMMUNITY_TAG}." - exit 1 + run: bash ci/wait-community-compat.sh notify-crm: if: always() && needs.build.result != 'skipped' @@ -163,69 +157,25 @@ jobs: runs-on: ubuntu-latest needs: [build, sync-console-ee] steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Report community artifact to CRM env: WEBHOOK_URL: ${{ secrets.RELEASE_WEBHOOK_URL }} WEBHOOK_SECRET: ${{ secrets.RELEASE_WEBHOOK_SECRET }} BUILD_RESULT: ${{ needs.build.result }} COMPAT_SYNC_RESULT: ${{ needs.sync-console-ee.result }} - CRM_ENVIRONMENT: artifacts COMMIT_SHA: ${{ needs.build.outputs.commit_sha || github.sha }} IMAGE_DIGEST: ${{ needs.build.outputs.image_digest }} - VERSION: ${{ github.ref_name }} - IMAGE_REF: ghcr.io/cognipeer/console:${{ github.ref_name }} + RELEASE_VERSION: ${{ github.ref_name }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | set -euo pipefail - if [[ -z "${WEBHOOK_URL}" || -z "${WEBHOOK_SECRET}" ]]; then - echo "::error::RELEASE_WEBHOOK_URL / RELEASE_WEBHOOK_SECRET are required." - exit 1 - fi - - STATUS="succeeded" + RELEASE_STATUS=succeeded if [[ "${BUILD_RESULT}" != "success" || "${COMPAT_SYNC_RESULT}" != "success" ]]; then - STATUS="failed" + RELEASE_STATUS=failed fi - - IMMUTABLE_REF="" - if [[ -n "${IMAGE_DIGEST}" ]]; then - IMMUTABLE_REF="ghcr.io/cognipeer/console@${IMAGE_DIGEST}" - fi - - PAYLOAD_FILE="$(mktemp)" - trap 'rm -f "${PAYLOAD_FILE}"' EXIT - - jq -n \ - --arg product "console" \ - --arg repo "${GITHUB_REPOSITORY}" \ - --arg targetKey "community" \ - --arg environment "${CRM_ENVIRONMENT}" \ - --arg version "${VERSION}" \ - --arg commitSha "${COMMIT_SHA}" \ - --arg imageRef "${IMAGE_REF}" \ - --arg immutableRef "${IMMUTABLE_REF}" \ - --arg status "${STATUS}" \ - --arg actor "${GITHUB_ACTOR}" \ - --arg runUrl "${RUN_URL}" \ - '{ - product: $product, - repo: $repo, - targetKey: $targetKey, - environment: $environment, - version: $version, - commitSha: $commitSha, - imageRef: $imageRef, - immutableRef: (if $immutableRef == "" then null else $immutableRef end), - status: $status, - actor: $actor, - runUrl: $runUrl - }' > "${PAYLOAD_FILE}" - - SIGNATURE="$(openssl dgst -sha256 -hmac "${WEBHOOK_SECRET}" -hex "${PAYLOAD_FILE}" | sed 's/^.* //')" - curl --retry 3 --retry-delay 2 --retry-max-time 30 \ - --fail-with-body --silent --show-error \ - --request POST "${WEBHOOK_URL}" \ - --header "Content-Type: application/json" \ - --header "X-Cognipeer-Signature: sha256=${SIGNATURE}" \ - --data-binary "@${PAYLOAD_FILE}" + export RELEASE_STATUS + bash ci/notify-release-crm.sh diff --git a/.github/workflows/release-workflow-checks.yml b/.github/workflows/release-workflow-checks.yml new file mode 100644 index 00000000..e13ac735 --- /dev/null +++ b/.github/workflows/release-workflow-checks.yml @@ -0,0 +1,22 @@ +name: Release Workflow Checks + +on: + pull_request: + paths: ['.github/workflows/**', 'ci/**'] + push: + branches: [main] + paths: ['.github/workflows/**', 'ci/**'] + +permissions: + contents: read + +jobs: + contracts: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: node --test ci/*.test.mjs \ No newline at end of file diff --git a/ci/notify-release-crm.sh b/ci/notify-release-crm.sh new file mode 100644 index 00000000..f6522d50 --- /dev/null +++ b/ci/notify-release-crm.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${WEBHOOK_URL:?WEBHOOK_URL is required}" +: "${WEBHOOK_SECRET:?WEBHOOK_SECRET is required}" +: "${COMMIT_SHA:?COMMIT_SHA is required}" +: "${RELEASE_VERSION:?RELEASE_VERSION is required}" +: "${RELEASE_STATUS:?RELEASE_STATUS is required}" +: "${RUN_URL:?RUN_URL is required}" + +PAYLOAD_FILE=$(mktemp) +RESPONSE_FILE=$(mktemp) +trap 'rm -f "${PAYLOAD_FILE}" "${RESPONSE_FILE}"' EXIT +IMMUTABLE_REF="" +if [[ -n "${IMAGE_DIGEST:-}" ]]; then + [[ "${IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "::error::Invalid Community digest."; exit 1; } + IMMUTABLE_REF="ghcr.io/cognipeer/console@${IMAGE_DIGEST}" +fi +jq -n \ + --arg repo "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" \ + --arg version "${RELEASE_VERSION}" \ + --arg commitSha "${COMMIT_SHA}" \ + --arg immutableRef "${IMMUTABLE_REF}" \ + --arg status "${RELEASE_STATUS}" \ + --arg actor "${GITHUB_ACTOR:-github-actions}" \ + --arg runUrl "${RUN_URL}" \ + '{ + requireExecution: true, product: "console", targetKey: "community", environment: "artifacts", + repo: $repo, version: $version, commitSha: $commitSha, + imageRef: ("ghcr.io/cognipeer/console:" + $version), + immutableRef: (if $immutableRef == "" then null else $immutableRef end), + status: $status, actor: $actor, runUrl: $runUrl + }' > "${PAYLOAD_FILE}" +SIGNATURE=$(openssl dgst -sha256 -hmac "${WEBHOOK_SECRET}" -hex "${PAYLOAD_FILE}" | sed 's/^.* //') +curl --retry 3 --retry-delay 2 --retry-max-time 30 \ + --connect-timeout 10 --max-time 30 --fail-with-body --silent --show-error \ + --request POST "${WEBHOOK_URL}" \ + --header "Content-Type: application/json" \ + --header "X-Cognipeer-Signature: sha256=${SIGNATURE}" \ + --data-binary "@${PAYLOAD_FILE}" --output "${RESPONSE_FILE}" +jq -e ' + .contractVersion == 2 and .status == "recorded" + and (.releaseId | type == "string" and test("^[A-Za-z0-9-]+$")) + and (.executionId | type == "string" and test("^[A-Za-z0-9-]+$")) + and .targetKey == "community" and .environmentKey == "artifacts" +' "${RESPONSE_FILE}" >/dev/null \ + || { echo "::error::CRM did not confirm the Community release execution."; exit 1; } +echo "CRM recorded Community ${RELEASE_VERSION}: ${RELEASE_STATUS}." \ No newline at end of file diff --git a/ci/registry-digest.sh b/ci/registry-digest.sh new file mode 100644 index 00000000..c057d55f --- /dev/null +++ b/ci/registry-digest.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +registry_digest() { + local image="$1" + local manifest error_file digest + error_file=$(mktemp) + if manifest=$(docker buildx imagetools inspect --format '{{json .Manifest}}' "${image}" 2>"${error_file}"); then + rm -f "${error_file}" + if ! digest=$(jq -er '.digest | select(type == "string" and test("^sha256:[a-f0-9]{64}$"))' <<<"${manifest}"); then + echo "::error::Invalid registry response for ${image}." >&2 + return 1 + fi + printf '%s\n' "${digest}" + elif grep -Eiq '(^|[^[:alpha:]_])(manifest unknown|manifest_unknown|name_unknown)([^[:alpha:]_]|$)' "${error_file}" \ + || grep -Fqx -e "ERROR: ${image}: not found" -e "Error: ${image}: not found" -e "${image}: not found" "${error_file}"; then + rm -f "${error_file}" + else + echo "::error::Cannot inspect ${image}; refusing to treat a registry error as absence." >&2 + cat "${error_file}" >&2 + rm -f "${error_file}" + return 1 + fi +} \ No newline at end of file diff --git a/ci/release-workflows.test.mjs b/ci/release-workflows.test.mjs new file mode 100644 index 00000000..6ec8a7d1 --- /dev/null +++ b/ci/release-workflows.test.mjs @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const directory = dirname(fileURLToPath(import.meta.url)); +const digest = `sha256:${'a'.repeat(64)}`; +const mock = ` +sleep() { :; } +gh() { + [[ "\${GH_FAILURE:-0}" != 1 && "$*" == *'contents/COMPAT.json?ref=main'* ]] || return 1 + printf '{"communityRef":"%s"}' "$PIN" | base64 +} +docker() { + local image="\${@: -1}" digest + if [[ "\${REGISTRY_FAILURE:-0}" == 1 ]]; then echo 'ERROR: 403 Forbidden' >&2; return 1; fi + if [[ "\${REGISTRY_MISSING:-0}" == 1 ]]; then echo "ERROR: $image: not found" >&2; return 1; fi + digest="$MOCK_DIGEST" + if [[ "$image" == *':sha-'* ]]; then digest="$SHA_DIGEST"; fi + printf '{"digest":"%s"}' "$digest" +} +curl() { + local destination='' + while [[ "$#" -gt 0 ]]; do + if [[ "$1" == --output ]]; then shift; destination="$1"; fi + shift + done + printf '%s' "$RECEIPT" > "$destination" +} +`; + +function execute(script, overrides = {}) { + const path = mkdtempSync(join(tmpdir(), 'community-release-test-')); + try { + const outputs = join(path, 'outputs'); + const result = spawnSync('bash', ['-e', '-u', '-o', 'pipefail'], { + input: `${mock}\nsource "$SCRIPT_FILE"\n`, encoding: 'utf8', timeout: 30_000, + env: { + ...process.env, SCRIPT_FILE: join(directory, script), GITHUB_OUTPUT: outputs, + COMMUNITY_TAG: 'v1.2.54-community', PIN: 'v1.2.54-community', CONSOLE_EE_REPOSITORY: 'Cognipeer/console-ee', + IMAGE_REF: 'ghcr.io/cognipeer/console:v1.2.54-community', SOURCE_SHA_REF: `ghcr.io/cognipeer/console:sha-${'b'.repeat(40)}`, + MOCK_DIGEST: digest, SHA_DIGEST: digest, WEBHOOK_URL: 'https://crm.invalid', WEBHOOK_SECRET: 'test-only', COMMIT_SHA: 'b'.repeat(40), + RELEASE_VERSION: 'v1.2.54-community', RELEASE_STATUS: 'succeeded', IMAGE_DIGEST: digest, GITHUB_REPOSITORY: 'Cognipeer/console', + RUN_URL: 'https://github.com/Cognipeer/console/actions/runs/42', + RECEIPT: JSON.stringify({ contractVersion: 2, status: 'recorded', releaseId: 'release-1', executionId: 'execution-1', targetKey: 'community', environmentKey: 'artifacts' }), + ...overrides, + }, + }); + assert.ifError(result.error); + return { status: result.status, output: result.stdout + result.stderr, outputs: existsSync(outputs) ? readFileSync(outputs, 'utf8') : '' }; + } finally { + rmSync(path, { recursive: true, force: true }); + } +} + +test('an already correct pin needs no new PR', () => { + const result = execute('wait-community-compat.sh'); + assert.equal(result.status, 0, result.output); +}); + +test('wrong pins and API failures never complete the wait', () => { + for (const overrides of [{ PIN: 'v1.2.53-community' }, { GH_FAILURE: '1' }]) { + assert.notEqual(execute('wait-community-compat.sh', overrides).status, 0); + } +}); + +test('reuses SHA-owned images and builds only when explicitly absent', () => { + const reuse = execute('resolve-image.sh'); + assert.equal(reuse.status, 0, reuse.output); + assert.match(reuse.outputs, /reuse=true/); + assert.ok(reuse.outputs.includes(digest)); + const absent = execute('resolve-image.sh', { REGISTRY_MISSING: '1' }); + assert.equal(absent.status, 0, absent.output); + assert.match(absent.outputs, /reuse=false/); +}); + +test('registry errors and incorrect ownership cannot allow a rebuild', () => { + for (const overrides of [{ REGISTRY_FAILURE: '1' }, { SHA_DIGEST: `sha256:${'c'.repeat(64)}` }]) { + const result = execute('resolve-image.sh', overrides); + assert.notEqual(result.status, 0); + assert.doesNotMatch(result.outputs, /reuse=false/); + } +}); + +test('callbacks require a current linked receipt', () => { + assert.equal(execute('notify-release-crm.sh').status, 0); + for (const receipt of ['{"status":"ignored"}', '{"status":"recorded","releaseId":null}', 'not json']) { + assert.notEqual(execute('notify-release-crm.sh', { RECEIPT: receipt }).status, 0); + } +}); + +test('workflow gates publishing and every embedded shell block parses', () => { + const parsed = spawnSync('yq', ['-o=json', '.', join(directory, '../.github/workflows/build-community.yml')], { encoding: 'utf8' }); + assert.ifError(parsed.error); + assert.equal(parsed.status, 0, parsed.stderr); + const workflow = JSON.parse(parsed.stdout); + const steps = workflow.jobs.build.steps; + const names = steps.map((step) => step.name); + assert.ok(names.indexOf('Verify managed release with CRM') < names.indexOf('Build & Push to GHCR')); + assert.equal(steps.find((step) => step.id === 'image').if, "steps.existing.outputs.reuse != 'true'"); + assert.deepEqual(workflow.jobs['notify-crm'].needs, ['build', 'sync-console-ee']); + for (const job of Object.values(workflow.jobs)) { + for (const step of job.steps ?? []) { + if (!step.run) continue; + const result = spawnSync('bash', ['-n'], { input: step.run, encoding: 'utf8' }); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); + } + } +}); \ No newline at end of file diff --git a/ci/resolve-image.sh b/ci/resolve-image.sh new file mode 100644 index 00000000..b9a8a881 --- /dev/null +++ b/ci/resolve-image.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${IMAGE_REF:?IMAGE_REF is required}" +: "${SOURCE_SHA_REF:?SOURCE_SHA_REF is required}" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +source "${ROOT_DIR}/registry-digest.sh" + +VERSION_DIGEST=$(registry_digest "${IMAGE_REF}") +SOURCE_DIGEST=$(registry_digest "${SOURCE_SHA_REF}") +if [[ -n "${VERSION_DIGEST}" ]]; then + [[ "${VERSION_DIGEST}" == "${SOURCE_DIGEST}" ]] \ + || { echo "::error::${IMAGE_REF} does not belong to the selected source SHA."; exit 1; } +elif [[ -n "${SOURCE_DIGEST}" ]]; then + docker buildx imagetools create --prefer-index=false --progress plain \ + --tag "${IMAGE_REF}" "${SOURCE_SHA_REF}@${SOURCE_DIGEST}" + VERSION_DIGEST=$(registry_digest "${IMAGE_REF}") + [[ "${VERSION_DIGEST}" == "${SOURCE_DIGEST}" ]] \ + || { echo "::error::Restoring the version tag changed the source digest."; exit 1; } +else + echo "reuse=false" >> "${GITHUB_OUTPUT}" + exit 0 +fi + +{ + echo "reuse=true" + echo "digest=${VERSION_DIGEST}" +} >> "${GITHUB_OUTPUT}" \ No newline at end of file diff --git a/ci/wait-community-compat.sh b/ci/wait-community-compat.sh new file mode 100644 index 00000000..9c329c77 --- /dev/null +++ b/ci/wait-community-compat.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${COMMUNITY_TAG:?COMMUNITY_TAG is required}" +: "${CONSOLE_EE_REPOSITORY:?CONSOLE_EE_REPOSITORY is required}" +for attempt in {1..180}; do + COMMUNITY_REF=$(gh api "repos/${CONSOLE_EE_REPOSITORY}/contents/COMPAT.json?ref=main" \ + --jq .content | tr -d '\n' | base64 --decode | jq -r .communityRef) + if [[ "${COMMUNITY_REF}" == "${COMMUNITY_TAG}" ]]; then + exit 0 + fi + if [[ "${attempt}" -lt 180 ]]; then sleep 10; fi +done +echo "::error::Console Enterprise compatibility pin was not observed for ${COMMUNITY_TAG}." +exit 1 \ No newline at end of file From 63881fd7c7ff2fc7f6ca43eec5f3c687c98b2932 Mon Sep 17 00:00:00 2001 From: Cihan Alagoz Date: Mon, 7 Sep 2026 18:54:50 +0300 Subject: [PATCH 2/2] fix(releases): bind Community artifacts to managed source identity --- .github/workflows/build-community.yml | 163 +++++++----------- .github/workflows/release-workflow-checks.yml | 36 ++++ ci/artifact-contract.test.mjs | 114 ++++++++++++ ci/notify-release-crm.sh | 49 ++++++ ci/registry-digest.sh | 37 ++++ ci/release-workflows.test.mjs | 82 +++++++++ ci/resolve-image.sh | 19 ++ ci/wait-community-compat.sh | 25 +++ 8 files changed, 423 insertions(+), 102 deletions(-) create mode 100644 .github/workflows/release-workflow-checks.yml create mode 100644 ci/artifact-contract.test.mjs create mode 100644 ci/notify-release-crm.sh create mode 100644 ci/registry-digest.sh create mode 100644 ci/release-workflows.test.mjs create mode 100644 ci/resolve-image.sh create mode 100644 ci/wait-community-compat.sh diff --git a/.github/workflows/build-community.yml b/.github/workflows/build-community.yml index 4248649b..1f9c5ad9 100644 --- a/.github/workflows/build-community.yml +++ b/.github/workflows/build-community.yml @@ -9,16 +9,36 @@ permissions: contents: read packages: write +concurrency: + group: console-community-${{ github.ref_name }} + cancel-in-progress: false + jobs: build: runs-on: self-hosted-runner-cgate-azure + environment: production + timeout-minutes: 90 outputs: - image_digest: ${{ steps.image.outputs.digest }} + image_digest: ${{ steps.identity.outputs.digest }} commit_sha: ${{ steps.vars.outputs.commit_sha }} steps: - name: Checkout uses: actions/checkout@v4 + - name: Capture release identity + id: vars + run: echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Verify managed release with CRM + env: + WEBHOOK_URL: ${{ secrets.RELEASE_WEBHOOK_URL }} + WEBHOOK_SECRET: ${{ secrets.RELEASE_WEBHOOK_SECRET }} + COMMIT_SHA: ${{ steps.vars.outputs.commit_sha }} + RELEASE_VERSION: ${{ github.ref_name }} + RELEASE_STATUS: pending + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: bash ci/notify-release-crm.sh + - name: Login to GHCR uses: docker/login-action@v3 with: @@ -29,8 +49,16 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Resolve existing image + id: existing + env: + IMAGE_REF: ghcr.io/cognipeer/console:${{ github.ref_name }} + SOURCE_COMMIT_SHA: ${{ steps.vars.outputs.commit_sha }} + run: bash ci/resolve-image.sh + - name: Build & Push to GHCR id: image + if: steps.existing.outputs.reuse != 'true' uses: docker/build-push-action@v6 with: context: . @@ -38,12 +66,29 @@ jobs: push: true cache-from: type=gha cache-to: type=gha,mode=max + provenance: mode=min tags: ghcr.io/cognipeer/console:${{ github.ref_name }} - - - name: Capture release identity - id: vars - if: always() - run: echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + labels: | + org.opencontainers.image.version=${{ github.ref_name }} + org.opencontainers.image.revision=${{ steps.vars.outputs.commit_sha }} + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} + + - name: Verify image identity + id: identity + env: + IMAGE_REF: ghcr.io/cognipeer/console:${{ github.ref_name }} + SOURCE_COMMIT_SHA: ${{ steps.vars.outputs.commit_sha }} + EXISTING_DIGEST: ${{ steps.existing.outputs.digest }} + BUILT_DIGEST: ${{ steps.image.outputs.digest }} + run: | + set -euo pipefail + source ci/registry-digest.sh + DIGEST="${EXISTING_DIGEST:-${BUILT_DIGEST:-}}" + [[ -n "${DIGEST}" && "$(registry_digest "${IMAGE_REF}")" == "${DIGEST}" ]] \ + || { echo "::error::Community image digest changed or is missing."; exit 1; } + assert_image_identity "${IMAGE_REF}" "${DIGEST}" "${SOURCE_COMMIT_SHA}" + echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" sync-console-ee: runs-on: self-hosted-runner-cgate-azure @@ -112,50 +157,9 @@ jobs: env: GH_TOKEN: ${{ steps.app-token.outputs.token }} COMMUNITY_TAG: ${{ github.ref_name }} + COMMUNITY_SHA: ${{ needs.build.outputs.commit_sha }} CONSOLE_EE_REPOSITORY: Cognipeer/console-ee - run: | - set -euo pipefail - TITLE="chore(compat): pin ${COMMUNITY_TAG}" - MAX_ATTEMPTS=180 - - for ((attempt=1; attempt<=MAX_ATTEMPTS; attempt++)); do - PR_JSON=$(gh api \ - --method GET "repos/${CONSOLE_EE_REPOSITORY}/pulls" \ - -f state=all \ - -f head="Cognipeer:automation/community-compat" \ - -f base=main \ - -f per_page=100 \ - | jq --arg title "${TITLE}" \ - '[.[] | select(.title == $title)] | sort_by(.number) | last') - - if [[ "${PR_JSON}" != "null" ]]; then - PR_STATE=$(jq -r .state <<<"${PR_JSON}") - MERGED_AT=$(jq -r '.merged_at // ""' <<<"${PR_JSON}") - if [[ "${PR_STATE}" == "closed" && -z "${MERGED_AT}" ]]; then - echo "::error::Compatibility PR for ${COMMUNITY_TAG} was closed without merging." - exit 1 - fi - - if [[ -n "${MERGED_AT}" ]]; then - COMMUNITY_REF=$(gh api \ - "repos/${CONSOLE_EE_REPOSITORY}/contents/COMPAT.json?ref=main" \ - --jq .content \ - | tr -d '\n' \ - | base64 --decode \ - | jq -r .communityRef) - if [[ "${COMMUNITY_REF}" == "${COMMUNITY_TAG}" ]]; then - exit 0 - fi - fi - fi - - if [[ "${attempt}" -lt "${MAX_ATTEMPTS}" ]]; then - sleep 10 - fi - done - - echo "::error::Console Enterprise compatibility merge was not observed for ${COMMUNITY_TAG}." - exit 1 + run: bash ci/wait-community-compat.sh notify-crm: if: always() && needs.build.result != 'skipped' @@ -163,69 +167,24 @@ jobs: runs-on: self-hosted-runner-cgate-azure needs: [build, sync-console-ee] steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Report community artifact to CRM env: WEBHOOK_URL: ${{ secrets.RELEASE_WEBHOOK_URL }} WEBHOOK_SECRET: ${{ secrets.RELEASE_WEBHOOK_SECRET }} BUILD_RESULT: ${{ needs.build.result }} COMPAT_SYNC_RESULT: ${{ needs.sync-console-ee.result }} - CRM_ENVIRONMENT: artifacts COMMIT_SHA: ${{ needs.build.outputs.commit_sha || github.sha }} IMAGE_DIGEST: ${{ needs.build.outputs.image_digest }} - VERSION: ${{ github.ref_name }} - IMAGE_REF: ghcr.io/cognipeer/console:${{ github.ref_name }} + RELEASE_VERSION: ${{ github.ref_name }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | set -euo pipefail - - if [[ -z "${WEBHOOK_URL}" || -z "${WEBHOOK_SECRET}" ]]; then - echo "::error::RELEASE_WEBHOOK_URL / RELEASE_WEBHOOK_SECRET are required." - exit 1 - fi - - STATUS="succeeded" + RELEASE_STATUS=succeeded if [[ "${BUILD_RESULT}" != "success" || "${COMPAT_SYNC_RESULT}" != "success" ]]; then - STATUS="failed" + RELEASE_STATUS=failed fi - - IMMUTABLE_REF="" - if [[ -n "${IMAGE_DIGEST}" ]]; then - IMMUTABLE_REF="ghcr.io/cognipeer/console@${IMAGE_DIGEST}" - fi - - PAYLOAD_FILE="$(mktemp)" - trap 'rm -f "${PAYLOAD_FILE}"' EXIT - - jq -n \ - --arg product "console" \ - --arg repo "${GITHUB_REPOSITORY}" \ - --arg targetKey "community" \ - --arg environment "${CRM_ENVIRONMENT}" \ - --arg version "${VERSION}" \ - --arg commitSha "${COMMIT_SHA}" \ - --arg imageRef "${IMAGE_REF}" \ - --arg immutableRef "${IMMUTABLE_REF}" \ - --arg status "${STATUS}" \ - --arg actor "${GITHUB_ACTOR}" \ - --arg runUrl "${RUN_URL}" \ - '{ - product: $product, - repo: $repo, - targetKey: $targetKey, - environment: $environment, - version: $version, - commitSha: $commitSha, - imageRef: $imageRef, - immutableRef: (if $immutableRef == "" then null else $immutableRef end), - status: $status, - actor: $actor, - runUrl: $runUrl - }' > "${PAYLOAD_FILE}" - - SIGNATURE="$(openssl dgst -sha256 -hmac "${WEBHOOK_SECRET}" -hex "${PAYLOAD_FILE}" | sed 's/^.* //')" - curl --retry 3 --retry-delay 2 --retry-max-time 30 \ - --fail-with-body --silent --show-error \ - --request POST "${WEBHOOK_URL}" \ - --header "Content-Type: application/json" \ - --header "X-Cognipeer-Signature: sha256=${SIGNATURE}" \ - --data-binary "@${PAYLOAD_FILE}" + export RELEASE_STATUS + bash ci/notify-release-crm.sh diff --git a/.github/workflows/release-workflow-checks.yml b/.github/workflows/release-workflow-checks.yml new file mode 100644 index 00000000..131f5808 --- /dev/null +++ b/.github/workflows/release-workflow-checks.yml @@ -0,0 +1,36 @@ +name: Release Workflow Checks + +on: + pull_request: + paths: ['.github/workflows/**', 'ci/**'] + push: + branches: [main] + paths: ['.github/workflows/**', 'ci/**'] + +permissions: + contents: read + +jobs: + contracts: + runs-on: self-hosted-runner-cgate-azure + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - name: Install YAML test parser + run: | + set -euo pipefail + case "${RUNNER_ARCH}" in + X64) ARCH=amd64 ;; + ARM64) ARCH=arm64 ;; + *) echo "::error::Unsupported runner architecture."; exit 1 ;; + esac + mkdir -p "${RUNNER_TEMP}/release-tools" + curl -fsSL --retry 3 --connect-timeout 10 --max-time 120 \ + "https://github.com/mikefarah/yq/releases/download/v4.47.1/yq_linux_${ARCH}" \ + -o "${RUNNER_TEMP}/release-tools/yq" + chmod +x "${RUNNER_TEMP}/release-tools/yq" + echo "${RUNNER_TEMP}/release-tools" >> "$GITHUB_PATH" + - run: node --test ci/*.test.mjs \ No newline at end of file diff --git a/ci/artifact-contract.test.mjs b/ci/artifact-contract.test.mjs new file mode 100644 index 00000000..16dc0e78 --- /dev/null +++ b/ci/artifact-contract.test.mjs @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const directory = dirname(fileURLToPath(import.meta.url)); +const digest = `sha256:${'a'.repeat(64)}`; +const sha = 'b'.repeat(40); +const labels = { + 'org.opencontainers.image.revision': sha, + 'org.opencontainers.image.version': 'v1.2.60-community', + 'org.opencontainers.image.source': 'https://github.com/Cognipeer/console', +}; +const receipt = { contractVersion: 2, status: 'recorded', releaseId: 'release-1', executionId: 'execution-1', targetKey: 'community', environmentKey: 'artifacts' }; +const mock = ` +docker() { + printf '%s\\n' "$*" >> "$CALLS" + if [[ "$REGISTRY_ERROR" != '' ]]; then echo "$REGISTRY_ERROR" >&2; return 1; fi + if [[ "$*" == *'.Manifest'* ]]; then printf '%s' "$MANIFEST"; else printf '%s' "$IMAGE_CONFIG"; fi +} +curl() { + [[ "$HTTP_FAILURE" == 0 ]] || return 22 + local output='' payload='' + while [[ "$#" -gt 0 ]]; do + case "$1" in + --output) shift; output="$1" ;; + --data-binary) shift; payload="\${1#@}" ;; + esac + shift + done + cp "$payload" "$PAYLOAD" + printf '%s' "$RECEIPT" > "$output" +} +`; + +function execute(script, overrides = {}) { + const path = mkdtempSync(join(tmpdir(), 'community-artifact-test-')); + const output = join(path, 'output'); + const payload = join(path, 'payload'); + const calls = join(path, 'calls'); + try { + const result = spawnSync('bash', ['-e', '-u', '-o', 'pipefail'], { + input: `${mock}\nsource "$SCRIPT_FILE"\n`, encoding: 'utf8', timeout: 10_000, + env: { + ...process.env, SCRIPT_FILE: join(directory, script), GITHUB_OUTPUT: output, PAYLOAD: payload, CALLS: calls, + GITHUB_REPOSITORY: 'Cognipeer/console', GITHUB_SERVER_URL: 'https://github.com', + IMAGE_REF: 'ghcr.io/cognipeer/console:v1.2.60-community', SOURCE_COMMIT_SHA: sha, + REGISTRY_ERROR: '', MANIFEST: JSON.stringify({ digest }), IMAGE_CONFIG: JSON.stringify({ config: { Labels: labels } }), + WEBHOOK_URL: 'https://crm.invalid', WEBHOOK_SECRET: 'test-only', COMMIT_SHA: sha, + RELEASE_VERSION: 'v1.2.60-community', RELEASE_STATUS: 'succeeded', IMAGE_DIGEST: digest, + RUN_URL: 'https://github.com/Cognipeer/console/actions/runs/42', RECEIPT: JSON.stringify(receipt), HTTP_FAILURE: '0', + ...overrides, + }, + }); + assert.ifError(result.error); + return { + status: result.status, output: result.stdout + result.stderr, + outputs: existsSync(output) ? readFileSync(output, 'utf8') : '', + calls: existsSync(calls) ? readFileSync(calls, 'utf8') : '', + payload: existsSync(payload) ? JSON.parse(readFileSync(payload, 'utf8')) : null, + }; + } finally { rmSync(path, { recursive: true, force: true }); } +} + +test('reuses only metadata-verified version images, without a SHA tag', () => { + const result = execute('resolve-image.sh'); + assert.equal(result.status, 0, result.output); + assert.match(result.outputs, /reuse=true/); + assert.ok(result.calls.includes(`ghcr.io/cognipeer/console@${digest}`)); + assert.doesNotMatch(result.calls, /:sha-/); +}); + +test('only explicit registry absence allows a build', () => { + const absent = execute('resolve-image.sh', { REGISTRY_ERROR: 'ERROR: manifest unknown' }); + assert.equal(absent.status, 0, absent.output); + assert.equal(absent.outputs, 'reuse=false\n'); + for (const overrides of [{ REGISTRY_ERROR: 'ERROR: 403 Forbidden' }, { REGISTRY_ERROR: 'network timeout' }, { MANIFEST: '{}' }]) { + const result = execute('resolve-image.sh', overrides); + assert.notEqual(result.status, 0); + assert.equal(result.outputs, ''); + } +}); + +test('rejects missing or mismatched identity on every platform', () => { + const correct = { config: { Labels: labels } }; + const wrong = { config: { Labels: { ...labels, 'org.opencontainers.image.revision': 'c'.repeat(40) } } }; + for (const config of [{}, wrong, { 'linux/amd64': correct, 'linux/arm64': wrong }]) { + assert.notEqual(execute('resolve-image.sh', { IMAGE_CONFIG: JSON.stringify(config) }).status, 0); + } + assert.equal(execute('resolve-image.sh', { IMAGE_CONFIG: JSON.stringify({ 'linux/amd64': correct, 'linux/arm64': correct }) }).status, 0); +}); + +test('callbacks require an exact linked v2 receipt', () => { + const result = execute('notify-release-crm.sh'); + assert.equal(result.status, 0, result.output); + assert.equal(result.payload.requireExecution, true); + assert.equal(result.payload.immutableRef, `ghcr.io/cognipeer/console@${digest}`); + for (const response of [ + { status: 'ignored' }, { ...receipt, releaseId: null }, { ...receipt, executionId: null }, + { ...receipt, contractVersion: 1 }, { ...receipt, targetKey: 'saas' }, { ...receipt, environmentKey: 'production' }, + ]) assert.notEqual(execute('notify-release-crm.sh', { RECEIPT: JSON.stringify(response) }).status, 0); +}); + +test('never reports HTTP failure or missing success digest as success', () => { + for (const overrides of [{ HTTP_FAILURE: '1' }, { RECEIPT: 'not-json' }, { IMAGE_DIGEST: '' }]) { + assert.notEqual(execute('notify-release-crm.sh', overrides).status, 0); + } + for (const status of ['pending', 'failed']) { + assert.equal(execute('notify-release-crm.sh', { IMAGE_DIGEST: '', RELEASE_STATUS: status }).status, 0); + } +}); \ No newline at end of file diff --git a/ci/notify-release-crm.sh b/ci/notify-release-crm.sh new file mode 100644 index 00000000..d5d9e8cd --- /dev/null +++ b/ci/notify-release-crm.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${WEBHOOK_URL:?WEBHOOK_URL is required}" +: "${WEBHOOK_SECRET:?WEBHOOK_SECRET is required}" +: "${COMMIT_SHA:?COMMIT_SHA is required}" +: "${RELEASE_VERSION:?RELEASE_VERSION is required}" +: "${RELEASE_STATUS:?RELEASE_STATUS is required}" +: "${RUN_URL:?RUN_URL is required}" + +PAYLOAD_FILE=$(mktemp) +RESPONSE_FILE=$(mktemp) +trap 'rm -f "${PAYLOAD_FILE}" "${RESPONSE_FILE}"' EXIT +IMMUTABLE_REF="" +if [[ -n "${IMAGE_DIGEST:-}" ]]; then + [[ "${IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "::error::Invalid Community digest." >&2; exit 1; } + IMMUTABLE_REF="ghcr.io/cognipeer/console@${IMAGE_DIGEST}" +fi +if [[ "${RELEASE_STATUS}" == "succeeded" && -z "${IMMUTABLE_REF}" ]]; then + echo "::error::Community success requires an immutable image digest." >&2 + exit 1 +fi +jq -n \ + --arg repo "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" \ + --arg version "${RELEASE_VERSION}" --arg commitSha "${COMMIT_SHA}" \ + --arg immutableRef "${IMMUTABLE_REF}" --arg status "${RELEASE_STATUS}" \ + --arg actor "${GITHUB_ACTOR:-github-actions}" --arg runUrl "${RUN_URL}" \ + '{ + requireExecution: true, product: "console", targetKey: "community", environment: "artifacts", + repo: $repo, version: $version, commitSha: $commitSha, + imageRef: ("ghcr.io/cognipeer/console:" + $version), + immutableRef: (if $immutableRef == "" then null else $immutableRef end), + status: $status, actor: $actor, runUrl: $runUrl + }' > "${PAYLOAD_FILE}" +SIGNATURE=$(openssl dgst -sha256 -hmac "${WEBHOOK_SECRET}" -hex "${PAYLOAD_FILE}" | sed 's/^.* //') +curl --retry 3 --retry-delay 2 --retry-max-time 30 \ + --connect-timeout 10 --max-time 30 --fail-with-body --silent --show-error \ + --request POST "${WEBHOOK_URL}" \ + --header "Content-Type: application/json" \ + --header "X-Cognipeer-Signature: sha256=${SIGNATURE}" \ + --data-binary "@${PAYLOAD_FILE}" --output "${RESPONSE_FILE}" +jq -e ' + .contractVersion == 2 and .status == "recorded" + and (.releaseId | type == "string" and test("^[A-Za-z0-9-]+$")) + and (.executionId | type == "string" and test("^[A-Za-z0-9-]+$")) + and .targetKey == "community" and .environmentKey == "artifacts" +' "${RESPONSE_FILE}" >/dev/null \ + || { echo "::error::CRM did not confirm the Community release execution." >&2; exit 1; } +echo "CRM recorded Community ${RELEASE_VERSION}: ${RELEASE_STATUS}." \ No newline at end of file diff --git a/ci/registry-digest.sh b/ci/registry-digest.sh new file mode 100644 index 00000000..98d43e2d --- /dev/null +++ b/ci/registry-digest.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +registry_digest() { + local image="$1" manifest error_file digest + error_file=$(mktemp) + if manifest=$(docker buildx imagetools inspect --format '{{json .Manifest}}' "${image}" 2>"${error_file}"); then + rm -f "${error_file}" + digest=$(jq -er '.digest | select(type == "string" and test("^sha256:[a-f0-9]{64}$"))' <<<"${manifest}") \ + || { echo "::error::Invalid registry response for ${image}." >&2; return 1; } + printf '%s\n' "${digest}" + elif grep -Eiq '(^|[^[:alpha:]_])(manifest unknown|manifest_unknown|name_unknown)([^[:alpha:]_]|$)' "${error_file}" \ + || grep -Fqx -e "ERROR: ${image}: not found" -e "Error: ${image}: not found" -e "${image}: not found" "${error_file}"; then + rm -f "${error_file}" + else + echo "::error::Cannot inspect ${image}; refusing to treat a registry error as absence." >&2 + cat "${error_file}" >&2 + rm -f "${error_file}" + return 1 + fi +} + +assert_image_identity() { + local image="$1" digest="$2" revision="$3" image_config + [[ "${digest}" =~ ^sha256:[0-9a-f]{64}$ && "${revision}" =~ ^[0-9a-f]{40}$ ]] \ + || { echo "::error::Invalid image identity." >&2; return 1; } + : "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" + image_config=$(docker buildx imagetools inspect --format '{{json .Image}}' "${image%:*}@${digest}") || return 1 + jq -e --arg revision "${revision}" --arg version "${image##*:}" \ + --arg source "${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY}" ' + [if has("config") then . else .[] end] + | length > 0 and all(.[]; + .config.Labels["org.opencontainers.image.revision"] == $revision + and .config.Labels["org.opencontainers.image.version"] == $version + and .config.Labels["org.opencontainers.image.source"] == $source) + ' <<<"${image_config}" >/dev/null \ + || { echo "::error::Image metadata does not match the selected source and version." >&2; return 1; } +} \ No newline at end of file diff --git a/ci/release-workflows.test.mjs b/ci/release-workflows.test.mjs new file mode 100644 index 00000000..bbe082ae --- /dev/null +++ b/ci/release-workflows.test.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const directory = dirname(fileURLToPath(import.meta.url)); +const sha = 'b'.repeat(40); +const compat = { communityRepo: 'Cognipeer/console', communityRef: 'v1.2.60-community', communitySha: sha }; + +function waitForPin(pin = compat, overrides = {}) { + const result = spawnSync('bash', ['-e', '-u', '-o', 'pipefail'], { + input: ` + gh() { echo 'gh must not be required' >&2; return 127; } + sleep() { :; } + curl() { [[ "$HTTP_FAILURE" == 0 ]] || return 22; printf '%s' "$PIN"; } + source "$SCRIPT_FILE" + `, + encoding: 'utf8', timeout: 30_000, + env: { + ...process.env, SCRIPT_FILE: join(directory, 'wait-community-compat.sh'), + GH_TOKEN: 'test-token', GITHUB_REPOSITORY: 'Cognipeer/console', + CONSOLE_EE_REPOSITORY: 'Cognipeer/console-ee', COMMUNITY_TAG: compat.communityRef, + COMMUNITY_SHA: sha, PIN: JSON.stringify(pin), HTTP_FAILURE: '0', ...overrides, + }, + }); + assert.ifError(result.error); + return { status: result.status, output: result.stdout + result.stderr }; +} + +test('accepts an exact already-merged pin without gh or a new PR', () => { + const result = waitForPin(); + assert.equal(result.status, 0, result.output); +}); + +test('does not accept a moved tag, missing SHA, wrong repo or older pin', () => { + for (const pin of [ + { ...compat, communitySha: 'c'.repeat(40) }, + { ...compat, communitySha: undefined }, + { ...compat, communityRepo: 'other/console' }, + { ...compat, communityRef: 'v1.2.59-community' }, + ]) { + const result = waitForPin(pin); + assert.notEqual(result.status, 0); + assert.match(result.output, /did not pin the exact Community/); + } +}); + +test('rejects transport errors, malformed responses and invalid source identity', () => { + for (const [pin, overrides] of [ + [compat, { HTTP_FAILURE: '1' }], + [compat, { PIN: 'not-json' }], + [null, {}], + [compat, { COMMUNITY_SHA: '' }], + ]) { + assert.notEqual(waitForPin(pin, overrides).status, 0); + } +}); + +test('workflow preserves self-hosted runners and gates publishing on identity and receipts', () => { + const parsed = spawnSync(process.env.YQ_BINARY || 'yq', ['-o=json', '.', join(directory, '../.github/workflows/build-community.yml')], { encoding: 'utf8' }); + assert.ifError(parsed.error); + assert.equal(parsed.status, 0, parsed.stderr); + const workflow = JSON.parse(parsed.stdout); + const steps = workflow.jobs.build.steps; + const names = steps.map((step) => step.name); + assert.ok(names.indexOf('Verify managed release with CRM') < names.indexOf('Build & Push to GHCR')); + assert.ok(names.indexOf('Resolve existing image') < names.indexOf('Build & Push to GHCR')); + assert.equal(steps.find((step) => step.id === 'image').if, "steps.existing.outputs.reuse != 'true'"); + assert.match(steps.find((step) => step.id === 'image').with.labels, /org.opencontainers.image.revision/); + assert.deepEqual(workflow.jobs['notify-crm'].needs, ['build', 'sync-console-ee']); + assert.ok(workflow.jobs['sync-console-ee'].steps.find((step) => step.run === 'bash ci/wait-community-compat.sh').env.COMMUNITY_SHA); + for (const job of Object.values(workflow.jobs)) { + assert.equal(job['runs-on'], 'self-hosted-runner-cgate-azure'); + for (const step of job.steps ?? []) { + if (!step.run) continue; + assert.doesNotMatch(step.run, /\bgh (api|pr)\b/); + const syntax = spawnSync('bash', ['-n'], { input: step.run, encoding: 'utf8' }); + assert.equal(syntax.status, 0, syntax.stderr); + } + } +}); \ No newline at end of file diff --git a/ci/resolve-image.sh b/ci/resolve-image.sh new file mode 100644 index 00000000..6d729ccb --- /dev/null +++ b/ci/resolve-image.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${IMAGE_REF:?IMAGE_REF is required}" +: "${SOURCE_COMMIT_SHA:?SOURCE_COMMIT_SHA is required}" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +source "${ROOT_DIR}/registry-digest.sh" + +VERSION_DIGEST=$(registry_digest "${IMAGE_REF}") +if [[ -z "${VERSION_DIGEST}" ]]; then + echo "reuse=false" >> "${GITHUB_OUTPUT}" + exit 0 +fi +assert_image_identity "${IMAGE_REF}" "${VERSION_DIGEST}" "${SOURCE_COMMIT_SHA}" +{ + echo "reuse=true" + echo "digest=${VERSION_DIGEST}" +} >> "${GITHUB_OUTPUT}" \ No newline at end of file diff --git a/ci/wait-community-compat.sh b/ci/wait-community-compat.sh new file mode 100644 index 00000000..4f85113f --- /dev/null +++ b/ci/wait-community-compat.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${GH_TOKEN:?GH_TOKEN is required}" +: "${COMMUNITY_TAG:?COMMUNITY_TAG is required}" +: "${COMMUNITY_SHA:?COMMUNITY_SHA is required}" +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${CONSOLE_EE_REPOSITORY:?CONSOLE_EE_REPOSITORY is required}" +[[ "${COMMUNITY_SHA}" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::Invalid Community commit SHA." >&2; exit 1; } + +for attempt in {1..180}; do + COMPAT=$(curl --fail --silent --show-error --connect-timeout 10 --max-time 30 \ + --header "Authorization: Bearer ${GH_TOKEN}" \ + --header "Accept: application/vnd.github.raw+json" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + "${GITHUB_API_URL:-https://api.github.com}/repos/${CONSOLE_EE_REPOSITORY}/contents/COMPAT.json?ref=main") + jq -e 'type == "object" and (.communityRef | type == "string")' <<<"${COMPAT}" >/dev/null + if jq -e --arg repo "${GITHUB_REPOSITORY}" --arg tag "${COMMUNITY_TAG}" --arg sha "${COMMUNITY_SHA}" \ + '.communityRepo == $repo and .communityRef == $tag and .communitySha == $sha' <<<"${COMPAT}" >/dev/null; then + exit 0 + fi + if [[ "${attempt}" -lt 180 ]]; then sleep 10; fi +done +echo "::error::Console Enterprise did not pin the exact Community tag and commit." >&2 +exit 1 \ No newline at end of file