Skip to content
Open
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
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ internal-config:
npm run parse --workspace=@supplier-config/excel-parser -- \
"$(PWD)/specifications.xlsx" --output-dir "$(PWD)/artifacts/config-store" --pretty

validate-config:
npm run parse --workspace=@supplier-config/excel-parser -- \
"$(PWD)/specifications.xlsx" --check-mappings
# ==============================================================================

${VERBOSE}.SILENT: \
Expand Down
46 changes: 37 additions & 9 deletions actions/ddb-publish/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,46 @@ runs:
exit 1
fi

# Prefer release assets when the action ref is a tag with a matching release in the action repo.
if gh release view "${ACTION_REF}" --repo "${ACTION_REPO}" >/dev/null 2>&1; then
echo "[ddb-publish] Found release for ref '${ACTION_REF}' in '${ACTION_REPO}'. Downloading '${RELEASE_ASSET_NAME}'."
gh release download "${ACTION_REF}" --repo "${ACTION_REPO}" --pattern "${RELEASE_ASSET_NAME}" --dir "${download_dir}"
resolved_release_ref="${ACTION_REF}"
run_list_scope_description=""
run_list_scope_args=()

if [[ "${ACTION_REF}" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "[ddb-publish] action_ref looks like a commit SHA. Attempting to resolve it to a tag before checking releases."

resolved_tag="$({
gh api \
--paginate \
"repos/${ACTION_REPO}/tags?per_page=100" \
--jq ".[] | select(.commit.sha == \"${ACTION_REF}\") | .name"
} | head -n 1)"

if [[ -n "${resolved_tag}" ]]; then
resolved_release_ref="${resolved_tag}"
echo "[ddb-publish] Resolved commit '${ACTION_REF}' to tag '${resolved_release_ref}'."
else
echo "[ddb-publish] No tag matched commit '${ACTION_REF}'."
fi

run_list_scope_description="commit '${ACTION_REF}'"
run_list_scope_args=(--commit "${ACTION_REF}")
else
branch="${ACTION_REF#refs/heads/}"
run_list_scope_description="branch '${branch}'"
run_list_scope_args=(--branch "${branch}")
fi

# Prefer release assets when the action ref is, or resolves to, a tag with a matching release in the action repo.
if gh release view "${resolved_release_ref}" --repo "${ACTION_REPO}" >/dev/null 2>&1; then
echo "[ddb-publish] Found release for ref '${resolved_release_ref}' in '${ACTION_REPO}'. Downloading '${RELEASE_ASSET_NAME}'."
gh release download "${resolved_release_ref}" --repo "${ACTION_REPO}" --pattern "${RELEASE_ASSET_NAME}" --dir "${download_dir}"
tar -xzf "${download_dir}/${RELEASE_ASSET_NAME}" -C "${unpack_dir}"
echo "[ddb-publish] Bundle extracted from release asset."
exit 0
fi

# Otherwise treat the ref as a branch-like ref and fetch the latest successful CI artifact from the action repo.
branch="${ACTION_REF#refs/heads/}"
echo "[ddb-publish] No release found for ref '${ACTION_REF}'. Falling back to latest workflow artifact on branch '${branch}' from '${ACTION_REPO}'."
# Otherwise fetch the latest successful CI artifact using the original branch or commit from the action repo.
echo "[ddb-publish] No release found for ref '${resolved_release_ref}'. Falling back to latest workflow artifact on ${run_list_scope_description} from '${ACTION_REPO}'."

run_id=""
workflow_used=""
Expand All @@ -82,7 +110,7 @@ runs:
run_id_candidate="$(gh run list \
--repo "${ACTION_REPO}" \
--workflow "${workflow_file}" \
--branch "${branch}" \
"${run_list_scope_args[@]}" \
--status success \
--json databaseId \
--jq '.[0].databaseId' 2>/tmp/ddb_publish_run_list_err.log)"
Expand All @@ -109,7 +137,7 @@ runs:
done

if [[ -z "${run_id}" || "${run_id}" == "null" ]]; then
echo "ERROR: Could not find a successful run on branch '${branch}' in '${ACTION_REPO}' containing artifact '${WORKFLOW_ARTIFACT_NAME}'. Checked workflows: stage-3-build.yaml, cicd-1-pull-request.yaml." >&2
echo "ERROR: Could not find a successful run for ${run_list_scope_description} in '${ACTION_REPO}' containing artifact '${WORKFLOW_ARTIFACT_NAME}'. Checked workflows: stage-3-build.yaml, cicd-1-pull-request.yaml." >&2
exit 1
fi

Expand Down
46 changes: 37 additions & 9 deletions actions/eventbridge-publish/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,18 +52,46 @@ runs:
exit 1
fi

# Prefer release assets when the action ref is a tag with a matching release in the action repo.
if gh release view "${ACTION_REF}" --repo "${ACTION_REPO}" >/dev/null 2>&1; then
echo "[eventbridge-publish] Found release for ref '${ACTION_REF}' in '${ACTION_REPO}'. Downloading '${RELEASE_ASSET_NAME}'."
gh release download "${ACTION_REF}" --repo "${ACTION_REPO}" --pattern "${RELEASE_ASSET_NAME}" --dir "${download_dir}"
resolved_release_ref="${ACTION_REF}"
run_list_scope_description=""
run_list_scope_args=()

if [[ "${ACTION_REF}" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "[eventbridge-publish] action_ref looks like a commit SHA. Attempting to resolve it to a tag before checking releases."

resolved_tag="$({
gh api \
--paginate \
"repos/${ACTION_REPO}/tags?per_page=100" \
--jq ".[] | select(.commit.sha == \"${ACTION_REF}\") | .name"
} | head -n 1)"

if [[ -n "${resolved_tag}" ]]; then
resolved_release_ref="${resolved_tag}"
echo "[eventbridge-publish] Resolved commit '${ACTION_REF}' to tag '${resolved_release_ref}'."
else
echo "[eventbridge-publish] No tag matched commit '${ACTION_REF}'."
fi

run_list_scope_description="commit '${ACTION_REF}'"
run_list_scope_args=(--commit "${ACTION_REF}")
else
branch="${ACTION_REF#refs/heads/}"
run_list_scope_description="branch '${branch}'"
run_list_scope_args=(--branch "${branch}")
fi

# Prefer release assets when the action ref is, or resolves to, a tag with a matching release in the action repo.
if gh release view "${resolved_release_ref}" --repo "${ACTION_REPO}" >/dev/null 2>&1; then
echo "[eventbridge-publish] Found release for ref '${resolved_release_ref}' in '${ACTION_REPO}'. Downloading '${RELEASE_ASSET_NAME}'."
gh release download "${resolved_release_ref}" --repo "${ACTION_REPO}" --pattern "${RELEASE_ASSET_NAME}" --dir "${download_dir}"
tar -xzf "${download_dir}/${RELEASE_ASSET_NAME}" -C "${unpack_dir}"
echo "[eventbridge-publish] Bundle extracted from release asset."
exit 0
fi

# Otherwise treat the ref as a branch-like ref and fetch the latest successful CI artifact from the action repo.
branch="${ACTION_REF#refs/heads/}"
echo "[eventbridge-publish] No release found for ref '${ACTION_REF}'. Falling back to latest workflow artifact on branch '${branch}' from '${ACTION_REPO}'."
# Otherwise fetch the latest successful CI artifact using the original branch or commit from the action repo.
echo "[eventbridge-publish] No release found for ref '${resolved_release_ref}'. Falling back to latest workflow artifact on ${run_list_scope_description} from '${ACTION_REPO}'."

run_id=""
workflow_used=""
Expand All @@ -75,7 +103,7 @@ runs:
run_id_candidate="$(gh run list \
--repo "${ACTION_REPO}" \
--workflow "${workflow_file}" \
--branch "${branch}" \
"${run_list_scope_args[@]}" \
--status success \
--json databaseId \
--jq '.[0].databaseId' 2>/tmp/eventbridge_publish_run_list_err.log)"
Expand All @@ -102,7 +130,7 @@ runs:
done

if [[ -z "${run_id}" || "${run_id}" == "null" ]]; then
echo "ERROR: Could not find a successful run on branch '${branch}' in '${ACTION_REPO}' containing artifact '${WORKFLOW_ARTIFACT_NAME}'. Checked workflows: stage-3-build.yaml, cicd-1-pull-request.yaml." >&2
echo "ERROR: Could not find a successful run for ${run_list_scope_description} in '${ACTION_REPO}' containing artifact '${WORKFLOW_ARTIFACT_NAME}'. Checked workflows: stage-3-build.yaml, cicd-1-pull-request.yaml." >&2
exit 1
fi

Expand Down
78 changes: 75 additions & 3 deletions packages/ddb-publisher/src/__tests__/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ jest.mock("@supplier-config/file-store", () => {
return {
loadConfigStore: jest.fn(),
validateConfigStore: jest.fn(),
validateConfigStoreIntegrity: jest.fn(),
};
});

Expand Down Expand Up @@ -43,6 +44,7 @@ const fileStore = jest.requireMock(
) as unknown as {
loadConfigStore: jest.Mock;
validateConfigStore: jest.Mock;
validateConfigStoreIntegrity: jest.Mock;
};

const audit = jest.requireMock("../ddb/audit") as unknown as {
Expand All @@ -58,13 +60,17 @@ const awsClient = jest.requireMock("@aws-sdk/client-dynamodb") as unknown as {
};

describe("runPublisher", () => {
beforeEach(() => {
const validResult: ValidationResult = { ok: true, issues: [] };

fileStore.validateConfigStore.mockReturnValue(validResult);
fileStore.validateConfigStoreIntegrity.mockReturnValue(validResult);
});

it("should stop after validation when dryRun=true", async () => {
const store: LoadedConfigStore = { rootPath: "/tmp", records: [] };
fileStore.loadConfigStore.mockResolvedValue(store);

const validation: ValidationResult = { ok: true, issues: [] };
fileStore.validateConfigStore.mockReturnValue(validation);

await runPublisher({
sourcePath: "/tmp",
env: "draft",
Expand Down Expand Up @@ -107,6 +113,72 @@ describe("runPublisher", () => {
).rejects.toThrow("Config store validation failed");
});

it("should throw with a helpful message when integrity validation fails", async () => {
const store: LoadedConfigStore = {
rootPath: "/tmp",
records: [],
};
fileStore.loadConfigStore.mockResolvedValue(store);

fileStore.validateConfigStoreIntegrity.mockReturnValue({
ok: false,
issues: [
{
entity: "letter-variant",
sourceFilePath: "/tmp/letter-variant/variant-1.json",
message: "missing promoted supplier pack",
path: ["packSpecificationIds"],
},
],
});

await expect(
runPublisher({
sourcePath: "/tmp",
env: "draft",
tableName: "tbl",
dryRun: true,
force: false,
}),
).rejects.toThrow("Config store integrity validation failed");
});

it("should include issue detail lines in integrity validation errors", async () => {
const store: LoadedConfigStore = {
rootPath: "/tmp",
records: [],
};
fileStore.loadConfigStore.mockResolvedValue(store);

fileStore.validateConfigStoreIntegrity.mockReturnValue({
ok: false,
issues: [
{
entity: "letter-variant",
sourceFilePath: "/tmp/letter-variant/variant-1.json",
message: "missing promoted supplier pack",
path: ["packSpecificationIds"],
details: [
"packSpecification=pack-1 status=PROD => valid",
"supplierPack=supplier-pack-1 supplier=supplier-1 status=INT approval=APPROVED => invalid",
],
},
],
});

await expect(
runPublisher({
sourcePath: "/tmp",
env: "draft",
tableName: "tbl",
dryRun: true,
force: false,
}),
).rejects.toThrow(
"supplierPack=supplier-pack-1 supplier=supplier-1 status=INT approval=APPROVED => invalid",
);
});

it("should block upload when audit reports blocking items and force=false", async () => {
const store: LoadedConfigStore = {
rootPath: "/tmp",
Expand Down
27 changes: 26 additions & 1 deletion packages/ddb-publisher/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import {
loadConfigStore,
validateConfigStore,
validateConfigStoreIntegrity,
} from "@supplier-config/file-store";
import type {
ConfigRecord,
Expand All @@ -19,9 +20,13 @@ function issueLabel(i: {
sourceFilePath: string;
path?: (string | number)[];
message: string;
details?: string[];
}): string {
const pathPart = i.path?.length ? `:${i.path.join(".")}` : "";
return `${i.entity} ${i.sourceFilePath}${pathPart} - ${i.message}`;
const formattedDetails = i.details?.map((detail) => ` ${detail}`).join("\n");
const detailsPart = i.details?.length ? `\n${formattedDetails}` : "";

return `${i.entity} ${i.sourceFilePath}${pathPart} - ${i.message}${detailsPart}`;
}

function logStep(message: string): void {
Expand Down Expand Up @@ -81,6 +86,26 @@ async function runPublisher(plan: LoadPlan): Promise<void> {

logStep("Validation passed.");

logStep("Running config-store integrity checks...");
const integrityValidation = validateConfigStoreIntegrity(store);

if (!integrityValidation.ok) {
logStep(
`Integrity validation failed with ${integrityValidation.issues.length} issue(s).`,
);

const summary = integrityValidation.issues
.slice(0, 20)
.map((i: ValidationIssue) => issueLabel(i))
.join("\n");

throw new Error(
`Config store integrity validation failed with ${integrityValidation.issues.length} issue(s).\n${summary}`,
);
}

logStep("Integrity validation passed.");

if (plan.dryRun) {
logStep("Dry-run enabled; skipping DynamoDB audit and publish.");
return;
Expand Down
Loading
Loading