Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 61 additions & 102 deletions .github/workflows/build-community.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -29,21 +49,46 @@ 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: .
file: ./Dockerfile
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
Expand Down Expand Up @@ -112,120 +157,34 @@ 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'
environment: production
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
36 changes: 36 additions & 0 deletions .github/workflows/release-workflow-checks.yml
Original file line number Diff line number Diff line change
@@ -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
114 changes: 114 additions & 0 deletions ci/artifact-contract.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
});
Loading
Loading