Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
80 changes: 80 additions & 0 deletions .github/scripts/promote-bicep-types.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { readFile } from "node:fs/promises";
import monitorRemoteWorkflow from "./monitor-remote-workflow.mjs";
import { validatePublicationManifest } from "./publish-draft-release.mjs";

export function promotionRequests(manifest, tag, sourceSha) {
validatePublicationManifest(manifest, tag, sourceSha);
const version = tag.replace(/^v/, "");
if (
!/^v\d+\.\d+\.\d+$/.test(tag) ||
!["final", "patch"].includes(manifest.releaseType) ||
manifest.channel !== version.split(".").slice(0, 2).join(".")
) {
throw new Error("Bicep aliases require a verified stable release");
}
return ["radius", "aws"].map((target) => {
const artifacts = manifest.observed?.downstream?.ociArtifacts?.filter(
(artifact) => artifact.name === `${target}-bicep-types`
);
const artifact = artifacts?.[0];
if (
artifacts?.length !== 1 ||
artifact.reference !== `biceptypes.azurecr.io/${target}:${version}` ||
!/^sha256:[0-9a-f]{64}$/.test(artifact.descriptor?.digest)
) {
throw new Error(`No verified immutable ${target} Bicep artifact`);
}
return {
source_repository: "radius-project/radius",
source_sha: sourceSha,
registry_target: target,
version,
channel: manifest.channel,
digest: artifact.descriptor.digest,
release_identifier: `${version}-${sourceSha}-${target}`
};
});
}

export default async function promoteBicepTypes({
github,
context,
core,
monitor = monitorRemoteWorkflow
}) {
const manifest = JSON.parse(
await readFile(core.getInput("MANIFEST_FILE", { required: true }), "utf8")
);
const requests = promotionRequests(
manifest,
core.getInput("TAG", { required: true }),
core.getInput("SOURCE_SHA", { required: true })
);
for (const request of requests) {
const inputs = {
OWNER: "azure-octo",
REPO: "radius-publisher",
WORKFLOW_FILE: "promote-bicep-types.yml",
EVENT_TYPE: "bicep-types.promote",
RELEASE_IDENTIFIER: request.release_identifier,
CLIENT_PAYLOAD: JSON.stringify(request),
// Mutable aliases must be reconciled again, not inferred from an old run.
REUSE_SUCCESSFUL: "false",
MAX_WAIT_SECONDS: "300",
POLL_INTERVAL_SECONDS: "10"
};
await monitor({
github,
context,
core: {
...core,
getInput: (name) => inputs[name] ?? "",
setOutput: (name, value) =>
core.setOutput(`${request.registry_target}_${name}`, value),
setFailed: (message) => {
throw new Error(message);
}
}
});
}
}
133 changes: 133 additions & 0 deletions .github/scripts/promote-bicep-types_test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import promoteBicepTypes, {
promotionRequests
} from "./promote-bicep-types.mjs";
import { mandatoryChecks } from "./publish-draft-release.mjs";

const sourceSha = "a".repeat(40);
const tag = "v0.61.2";
const digest = `sha256:${"b".repeat(64)}`;
const fixture = () => ({
schemaVersion: 1,
tag,
sourceSha,
releaseType: "patch",
channel: "0.61",
checks: Object.fromEntries(mandatoryChecks.map((name) => [name, "verified"])),
observed: {
downstream: {
ociArtifacts: ["radius", "aws"].map((target) => ({
name: `${target}-bicep-types`,
reference: `biceptypes.azurecr.io/${target}:0.61.2`,
descriptor: { digest }
}))
}
}
});

test("promotion requests bind both aliases to the verified immutable digests", () => {
const requests = promotionRequests(fixture(), tag, sourceSha);
assert.deepEqual(
requests.map((request) => request.registry_target),
["radius", "aws"]
);
for (const request of requests) {
assert.equal(request.digest, digest);
assert.equal(request.version, "0.61.2");
assert.equal(request.channel, "0.61");
assert.equal(request.source_sha, sourceSha);
assert.equal(
request.release_identifier,
`0.61.2-${sourceSha}-${request.registry_target}`
);
}
});

test("unverified, prerelease, conflicting and incomplete manifests cannot promote", () => {
for (const mutate of [
(manifest) => {
manifest.checks.installation = "pending";
},
(manifest) => {
manifest.releaseType = "rc";
},
(manifest) => {
manifest.channel = "0.62";
},
(manifest) => {
manifest.sourceSha = "c".repeat(40);
},
(manifest) => {
manifest.observed.downstream.ociArtifacts.pop();
},
(manifest) => {
manifest.observed.downstream.ociArtifacts[0].reference =
"biceptypes.azurecr.io/radius:0.61";
},
(manifest) => {
manifest.observed.downstream.ociArtifacts[0].descriptor.digest =
"invalid";
}
]) {
const manifest = fixture();
mutate(manifest);
assert.throws(() => promotionRequests(manifest, tag, sourceSha));
}
});

test("promotion reconciles successful old runs and stops immediately on failure", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "bicep-promotion-"));
try {
const manifestFile = path.join(directory, "manifest.json");
await writeFile(manifestFile, JSON.stringify(fixture()));
const inputs = {
MANIFEST_FILE: manifestFile,
TAG: tag,
SOURCE_SHA: sourceSha
};
const outputs = {};
const core = {
getInput: (name) => inputs[name] ?? "",
setOutput: (name, value) => {
outputs[name] = value;
}
};
const requests = [];
await promoteBicepTypes({
github: {},
context: {},
core,
monitor: async ({ core }) => {
assert.equal(core.getInput("REUSE_SUCCESSFUL"), "false");
assert.equal(core.getInput("EVENT_TYPE"), "bicep-types.promote");
const request = JSON.parse(core.getInput("CLIENT_PAYLOAD"));
requests.push(request);
core.setOutput("run_url", request.registry_target);
}
});
assert.equal(requests.length, 2);
assert.deepEqual(outputs, { radius_run_url: "radius", aws_run_url: "aws" });

let attempts = 0;
await assert.rejects(
() =>
promoteBicepTypes({
github: {},
context: {},
core,
monitor: async ({ core }) => {
attempts++;
core.setFailed("Publisher digest mismatch");
}
}),
/Publisher digest mismatch/
);
assert.equal(attempts, 1);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
32 changes: 32 additions & 0 deletions .github/scripts/release-assets_test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,38 @@ test("reports optional missing assets without writing", async () => {
assert.equal(state.outputs.all_found, "false");
});

test("optional staging downloads recover locks from a previous attempt", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "release-assets-"));
try {
const locks = [
{ id: 1, name: "production-image-digests.json", contents: "image lock" },
{ id: 2, name: "release-cli-oci.json", contents: "CLI lock" }
];
const state = fixture({ assets: locks });
Object.assign(state.inputs, {
OWNER: "radius-project",
REPO: "radius",
TAG: "v0.61.0",
MODE: "download",
OUTPUT_DIR: root,
OPTIONAL: "true"
});
for (const lock of locks) {
state.inputs.NAMES = JSON.stringify([lock.name]);
await releaseAssets(state);
assert.equal(state.outputs.all_found, "true");
assert.equal(
await readFile(path.join(root, lock.name), "utf8"),
lock.contents
);
}
assert.deepEqual(state.calls.deleted, []);
assert.deepEqual(state.calls.uploaded, []);
} finally {
await rm(root, { recursive: true, force: true });
}
});

test("reuses an identical release asset", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "release-assets-"));
try {
Expand Down
38 changes: 36 additions & 2 deletions .github/scripts/release-controller_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,9 @@ test_approval_gate_and_bindings() {
'.jobs."publish-deployment-engine".steps[] |
select(.id == "existing-image") |
((has("continue-on-error") | not) and
(.run | contains("--allow-stale")))' \
"image inspection must report stale state and fail on errors"
(.run | contains("--allow-absent")) and
(.run | contains("--allow-stale") | not))' \
"immutable image conflicts must fail rather than dispatch a replacement"
assert_contains "${ENTRY}" \
"if [[ \"\${RESOLVE_RESULT}\" == \"failure\" ]]" \
"entry summary must suggest remediation only after a failure"
Expand Down Expand Up @@ -350,6 +351,38 @@ test_release_build_contract() {
"Prepare Release must reject an unmigrated release branch before opening a pull request"
}

test_de_bridge_uses_immutable_destinations() {
if ! (
directory="$(mktemp -d "${TMPDIR:-/tmp}/de-bridge-test-XXXXXX")"
trap 'rm -rf "${directory}"' EXIT
script="$(yq -r '.jobs."dispatch-publish".steps[] |
select(.id == "payload") | .run' "${WORKFLOWS}/publish-de-image.yaml")"
for tag in latest pr-42 0.61.0-rc.1 0.61.2; do
source_tag="${tag}"
[[ "${tag}" != "0.61.2" ]] || source_tag=0.61
SRC_IMAGE=example.azurecr.io/deployment-engine \
DEST_IMAGE=ghcr.io/radius-project/deployment-engine \
TAG="${tag}" SOURCE_TAG="${source_tag}" \
EXPECTED_REVISION=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
EXPECTED_REF=refs/tags/v0.61.2 \
GITHUB_OUTPUT="${directory}/${tag}" \
bash -euo pipefail -c "${script}" || exit 1
grep -Fxq "tag=${tag}" "${directory}/${tag}" || exit 1
grep -Fxq "source_tag=${source_tag}" "${directory}/${tag}" || exit 1
done
if TAG=0.61 GITHUB_OUTPUT="${directory}/channel" \
bash -euo pipefail -c "${script}" >"${directory}/error" 2>&1; then
exit 1
fi
grep -Fq "promoted only by the approved release finalizer" \
"${directory}/error"
); then
fail_test "the DE bridge can move stable aliases or lose its source tag"
return
fi
((++PASS))
}

main() {
command -v yq >/dev/null || {
echo "yq is required" >&2
Expand All @@ -362,6 +395,7 @@ main() {
test_unknown_dispatch_summary
test_stage_order
test_release_build_contract
test_de_bridge_uses_immutable_destinations
test_identity_and_cleanup

if ((FAIL > 0)); then
Expand Down
Loading
Loading