mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Detect orphaned stable delivery versions
A failed v6.4.2 release left newer stable-shaped source and public container tags behind after its GitHub Release packet was removed. Extend the scheduled continuity audit across the stable tag and public registry frontier so this partial publication cannot remain hidden behind an older latest release. Change-source: pulse-maintainer
This commit is contained in:
@@ -78,6 +78,62 @@ jobs:
|
||||
--diagnostic release-continuity-evidence/release-diagnostic.json \
|
||||
--github-output "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Reject orphaned stable publication tags
|
||||
id: frontier
|
||||
if: ${{ !cancelled() && steps.release.outputs.referenceable == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
evidence=release-continuity-evidence
|
||||
gh api --paginate --slurp \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
-H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||
"repos/${REPOSITORY}/git/matching-refs/tags/v?per_page=100" \
|
||||
> "${evidence}/stable-refs.json"
|
||||
gh api --paginate --slurp \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
-H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||
"repos/${REPOSITORY}/releases?per_page=100" \
|
||||
> "${evidence}/releases.json"
|
||||
|
||||
fetch_registry_tags() {
|
||||
local registry_name="$1" auth_url="$2" registry_url="$3" image="$4" output="$5"
|
||||
local token
|
||||
token="$(curl -fsSL --retry 4 --retry-delay 2 --retry-all-errors "${auth_url}")"
|
||||
token="$(jq -er '.token | select(type == "string" and length > 0)' <<<"${token}")"
|
||||
curl -fsSL --retry 4 --retry-delay 2 --retry-all-errors \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
"${registry_url}/v2/${image}/tags/list?n=10000" \
|
||||
| jq --arg name "${registry_name}/${image}" '.name = $name' > "${output}"
|
||||
}
|
||||
owner="${GITHUB_REPOSITORY_OWNER,,}"
|
||||
for image in pulse pulse-control-plane; do
|
||||
file_image="${image//-/_}"
|
||||
fetch_registry_tags \
|
||||
ghcr.io \
|
||||
"https://ghcr.io/token?service=ghcr.io&scope=repository:${owner}/${image}:pull" \
|
||||
https://ghcr.io "${owner}/${image}" \
|
||||
"${evidence}/ghcr-${file_image}-tags.json"
|
||||
done
|
||||
# The server image is public on both registries. The control-plane
|
||||
# image is publicly enumerable only on GHCR, so an unauthenticated
|
||||
# continuity audit must not depend on Docker Hub credentials.
|
||||
fetch_registry_tags \
|
||||
docker.io \
|
||||
"https://auth.docker.io/token?service=registry.docker.io&scope=repository:rcourtman/pulse:pull" \
|
||||
https://registry-1.docker.io rcourtman/pulse \
|
||||
"${evidence}/docker-pulse-tags.json"
|
||||
python3 scripts/release_control/release_continuity.py frontier \
|
||||
--release-json "${evidence}/release.json" \
|
||||
--stable-refs-json "${evidence}/stable-refs.json" \
|
||||
--releases-json "${evidence}/releases.json" \
|
||||
--registry-tags-json "${evidence}/ghcr-pulse-tags.json" \
|
||||
--registry-tags-json "${evidence}/docker-pulse-tags.json" \
|
||||
--registry-tags-json "${evidence}/ghcr-pulse_control_plane-tags.json" \
|
||||
--diagnostic "${evidence}/frontier-diagnostic.json"
|
||||
|
||||
- name: Bind the release activation marker
|
||||
id: activation
|
||||
# A structurally valid tag/id/SHA remains safe lookup data even when
|
||||
@@ -114,7 +170,7 @@ jobs:
|
||||
|
||||
- name: Verify immutable release and build provenance
|
||||
id: packet
|
||||
if: ${{ always() && steps.release.outcome == 'success' && steps.activation.outcome == 'success' && github.event.schedule != '17 */6 * * *' }}
|
||||
if: ${{ always() && steps.release.outcome == 'success' && steps.frontier.outcome == 'success' && steps.activation.outcome == 'success' && github.event.schedule != '17 */6 * * *' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
@@ -127,7 +183,7 @@ jobs:
|
||||
|
||||
- name: Authenticate every published release asset
|
||||
id: assets
|
||||
if: ${{ always() && steps.release.outcome == 'success' && steps.activation.outcome == 'success' && github.event.schedule != '17 */6 * * *' }}
|
||||
if: ${{ always() && steps.release.outcome == 'success' && steps.frontier.outcome == 'success' && steps.activation.outcome == 'success' && github.event.schedule != '17 */6 * * *' }}
|
||||
env:
|
||||
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
@@ -136,7 +192,7 @@ jobs:
|
||||
|
||||
- name: Verify exact-version container identities
|
||||
id: containers
|
||||
if: ${{ always() && steps.release.outcome == 'success' && steps.activation.outcome == 'success' && github.event.schedule != '17 */6 * * *' }}
|
||||
if: ${{ always() && steps.release.outcome == 'success' && steps.frontier.outcome == 'success' && steps.activation.outcome == 'success' && github.event.schedule != '17 */6 * * *' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
@@ -158,7 +214,7 @@ jobs:
|
||||
|
||||
- name: Verify stable container discovery aliases
|
||||
id: aliases
|
||||
if: ${{ always() && steps.release.outcome == 'success' && steps.activation.outcome == 'success' && github.event.schedule != '17 */6 * * *' }}
|
||||
if: ${{ always() && steps.release.outcome == 'success' && steps.frontier.outcome == 'success' && steps.activation.outcome == 'success' && github.event.schedule != '17 */6 * * *' }}
|
||||
env:
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
EXPECTED_SERVER_DIGEST: ${{ steps.activation.outputs.server_image_digest }}
|
||||
@@ -171,7 +227,7 @@ jobs:
|
||||
|
||||
- name: Verify exact-version Helm identity
|
||||
id: helm
|
||||
if: ${{ always() && steps.release.outcome == 'success' && steps.activation.outcome == 'success' && github.event.schedule != '17 */6 * * *' }}
|
||||
if: ${{ always() && steps.release.outcome == 'success' && steps.frontier.outcome == 'success' && steps.activation.outcome == 'success' && github.event.schedule != '17 */6 * * *' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
@@ -202,6 +258,7 @@ jobs:
|
||||
CONTROL_PLANE_IMAGE_DIGEST: ${{ steps.activation.outputs.control_plane_image_digest }}
|
||||
HELM_CHART_DIGEST: ${{ steps.activation.outputs.helm_chart_digest }}
|
||||
RELEASE_RESULT: ${{ steps.release.outcome }}
|
||||
FRONTIER_RESULT: ${{ steps.frontier.outcome }}
|
||||
ACTIVATION_RESULT: ${{ steps.activation.outcome }}
|
||||
PACKET_RESULT: ${{ steps.packet.outcome }}
|
||||
ASSET_RESULT: ${{ steps.assets.outcome }}
|
||||
@@ -216,7 +273,7 @@ jobs:
|
||||
if [ "${TRIGGER_SCHEDULE}" = '17 */6 * * *' ]; then
|
||||
mode=release_lock
|
||||
fi
|
||||
for diagnostic in release activation; do
|
||||
for diagnostic in release frontier activation; do
|
||||
path="release-continuity-evidence/${diagnostic}-diagnostic.json"
|
||||
if [ ! -s "${path}" ]; then
|
||||
jq -n \
|
||||
@@ -227,6 +284,7 @@ jobs:
|
||||
done
|
||||
jq -n \
|
||||
--slurpfile release_diagnostic release-continuity-evidence/release-diagnostic.json \
|
||||
--slurpfile frontier_diagnostic release-continuity-evidence/frontier-diagnostic.json \
|
||||
--slurpfile activation_diagnostic release-continuity-evidence/activation-diagnostic.json \
|
||||
--arg schema_version "1" \
|
||||
--arg checked_at "${checked_at}" \
|
||||
@@ -248,6 +306,7 @@ jobs:
|
||||
--arg control_plane_image_digest "${CONTROL_PLANE_IMAGE_DIGEST}" \
|
||||
--arg helm_chart_digest "${HELM_CHART_DIGEST}" \
|
||||
--arg release_result "${RELEASE_RESULT}" \
|
||||
--arg frontier_result "${FRONTIER_RESULT}" \
|
||||
--arg activation_result "${ACTIVATION_RESULT}" \
|
||||
--arg packet_result "${PACKET_RESULT}" \
|
||||
--arg asset_result "${ASSET_RESULT}" \
|
||||
@@ -281,10 +340,12 @@ jobs:
|
||||
},
|
||||
diagnostics: {
|
||||
release_identity: $release_diagnostic[0],
|
||||
stable_publication_frontier: $frontier_diagnostic[0],
|
||||
activation_binding: $activation_diagnostic[0]
|
||||
},
|
||||
checks: {
|
||||
release_resolution: $release_result,
|
||||
stable_publication_frontier: $frontier_result,
|
||||
activation_binding: $activation_result,
|
||||
immutable_packet_and_provenance: $packet_result,
|
||||
authenticated_assets: $asset_result,
|
||||
|
||||
@@ -161,12 +161,23 @@ func TestSecurityScanRevalidatesLatestStableDelivery(t *testing.T) {
|
||||
`"repos/${REPOSITORY}/releases/latest"`,
|
||||
`scripts/release_control/release_continuity.py release`,
|
||||
"release-diagnostic.json",
|
||||
"Reject orphaned stable publication tags",
|
||||
`"repos/${REPOSITORY}/git/matching-refs/tags/v?per_page=100"`,
|
||||
`"repos/${REPOSITORY}/releases?per_page=100"`,
|
||||
"https://ghcr.io/token?service=ghcr.io&scope=repository:",
|
||||
"https://auth.docker.io/token?service=registry.docker.io&scope=repository:",
|
||||
`"${registry_url}/v2/${image}/tags/list?n=10000"`,
|
||||
`--registry-tags-json "${evidence}/ghcr-pulse-tags.json"`,
|
||||
`--registry-tags-json "${evidence}/docker-pulse-tags.json"`,
|
||||
`scripts/release_control/release_continuity.py frontier`,
|
||||
"frontier-diagnostic.json",
|
||||
"Bind the release activation marker",
|
||||
`!cancelled()`,
|
||||
`steps.release.outputs.referenceable == 'true'`,
|
||||
`scripts/release_control/release_continuity.py activation`,
|
||||
"activation-diagnostic.json",
|
||||
`steps.activation.outcome == 'success'`,
|
||||
`steps.frontier.outcome == 'success'`,
|
||||
`./scripts/verify-github-release-integrity.sh`,
|
||||
`./scripts/validate-published-release.sh`,
|
||||
`PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}`,
|
||||
@@ -177,6 +188,7 @@ func TestSecurityScanRevalidatesLatestStableDelivery(t *testing.T) {
|
||||
`stable_container_aliases: $alias_result`,
|
||||
`activation_binding: $activation_result`,
|
||||
`release_identity: $release_diagnostic[0]`,
|
||||
`stable_publication_frontier: $frontier_diagnostic[0]`,
|
||||
`CONVERGENCE_RUN_ID: ${{ github.event.workflow_run.id }}`,
|
||||
`TRIGGER_SCHEDULE: ${{ github.event.schedule }}`,
|
||||
`mode=release_lock`,
|
||||
@@ -229,6 +241,7 @@ func TestSecurityScanRevalidatesLatestStableDelivery(t *testing.T) {
|
||||
step := workflowStepBlock(t, workflowJobBlock(t, workflow, "release-continuity"), stepName)
|
||||
for _, admission := range []string{
|
||||
`steps.release.outcome == 'success'`,
|
||||
`steps.frontier.outcome == 'success'`,
|
||||
`steps.activation.outcome == 'success'`,
|
||||
} {
|
||||
if !strings.Contains(step, admission) {
|
||||
|
||||
@@ -29,6 +29,10 @@ IMMUTABLE_REPLACEMENT_ACTION = (
|
||||
"Do not edit or repair the advertised release in place; restore the last known-good "
|
||||
"stable target if needed, then publish a corrected replacement through convergence."
|
||||
)
|
||||
ORPHAN_REPLACEMENT_ACTION = (
|
||||
"Do not advertise or repair the orphaned version in place; inspect the failed "
|
||||
"release run and publish a corrected replacement through convergence."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -86,6 +90,27 @@ RELEASE_RULES = {
|
||||
}
|
||||
|
||||
|
||||
FRONTIER_RULES = {
|
||||
"frontier_payload_invalid": (
|
||||
"GitHub did not return complete stable-tag and release inventories.",
|
||||
"Inspect the matching-refs and releases API responses before retrying.",
|
||||
),
|
||||
"stable_tag_without_release": (
|
||||
"A stable source tag newer than the advertised release has no GitHub Release packet.",
|
||||
ORPHAN_REPLACEMENT_ACTION,
|
||||
),
|
||||
"newer_stable_release_not_advertised": (
|
||||
"A published stable release is newer than the advertised latest release.",
|
||||
"Inspect the latest-release selection and customer aliases; do not move them without "
|
||||
"an immutable activation packet and successful convergence.",
|
||||
),
|
||||
"registry_stable_tag_beyond_latest": (
|
||||
"A public container registry exposes a stable version newer than the advertised release.",
|
||||
ORPHAN_REPLACEMENT_ACTION,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
ACTIVATION_RULES = {
|
||||
"activation_asset_invalid": (
|
||||
"The release does not list one uploaded, digest-bound activation marker.",
|
||||
@@ -270,6 +295,185 @@ def release_is_referenceable(payload: Any, failures: list[Violation]) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def stable_version(tag: str) -> tuple[int, int, int]:
|
||||
if STABLE_TAG.fullmatch(tag) is None:
|
||||
raise ValueError(f"invalid stable tag {tag!r}")
|
||||
parts = tuple(int(part) for part in tag.removeprefix("v").split("."))
|
||||
return (parts[0], parts[1], parts[2])
|
||||
|
||||
|
||||
def inventory_list(payload: Any) -> list[Any] | None:
|
||||
"""Flatten gh api's ordinary or --slurp pagination representation."""
|
||||
|
||||
if not isinstance(payload, list):
|
||||
return None
|
||||
if payload and all(isinstance(page, list) for page in payload):
|
||||
return [item for page in payload for item in page]
|
||||
return payload
|
||||
|
||||
|
||||
def frontier_violations(
|
||||
latest_release: Any,
|
||||
stable_refs_payload: Any,
|
||||
releases_payload: Any,
|
||||
registry_payloads: list[Any],
|
||||
) -> tuple[list[Violation], dict[str, Any]]:
|
||||
refs = inventory_list(stable_refs_payload)
|
||||
releases = inventory_list(releases_payload)
|
||||
latest_tag = latest_release.get("tag_name") if isinstance(latest_release, dict) else None
|
||||
identity: dict[str, Any] = {
|
||||
"advertised_tag": latest_tag,
|
||||
"newer_stable_tags": "",
|
||||
"orphaned_stable_tags": "",
|
||||
"unadvertised_published_stable_tags": "",
|
||||
"registry_stable_tags_beyond_latest": "",
|
||||
}
|
||||
if (
|
||||
not isinstance(latest_tag, str)
|
||||
or STABLE_TAG.fullmatch(latest_tag) is None
|
||||
or refs is None
|
||||
or releases is None
|
||||
):
|
||||
return (
|
||||
[
|
||||
violation(
|
||||
"frontier_payload_invalid",
|
||||
"$",
|
||||
"stable latest release plus tag and release arrays",
|
||||
{
|
||||
"latest_release": type(latest_release).__name__,
|
||||
"stable_refs": type(stable_refs_payload).__name__,
|
||||
"releases": type(releases_payload).__name__,
|
||||
},
|
||||
FRONTIER_RULES,
|
||||
)
|
||||
],
|
||||
identity,
|
||||
)
|
||||
|
||||
stable_tags: set[str] = set()
|
||||
for ref in refs:
|
||||
if not isinstance(ref, dict):
|
||||
return (
|
||||
[
|
||||
violation(
|
||||
"frontier_payload_invalid",
|
||||
"stable_refs",
|
||||
"array of Git ref objects",
|
||||
ref,
|
||||
FRONTIER_RULES,
|
||||
)
|
||||
],
|
||||
identity,
|
||||
)
|
||||
ref_name = ref.get("ref")
|
||||
if isinstance(ref_name, str):
|
||||
tag = ref_name.removeprefix("refs/tags/")
|
||||
if STABLE_TAG.fullmatch(tag):
|
||||
stable_tags.add(tag)
|
||||
|
||||
releases_by_tag: dict[str, dict[str, Any]] = {}
|
||||
for release in releases:
|
||||
if not isinstance(release, dict):
|
||||
return (
|
||||
[
|
||||
violation(
|
||||
"frontier_payload_invalid",
|
||||
"releases",
|
||||
"array of GitHub Release objects",
|
||||
release,
|
||||
FRONTIER_RULES,
|
||||
)
|
||||
],
|
||||
identity,
|
||||
)
|
||||
release_tag = release.get("tag_name")
|
||||
if isinstance(release_tag, str) and STABLE_TAG.fullmatch(release_tag):
|
||||
releases_by_tag[release_tag] = release
|
||||
|
||||
latest_version = stable_version(latest_tag)
|
||||
newer_tags = sorted(
|
||||
(tag for tag in stable_tags if stable_version(tag) > latest_version),
|
||||
key=stable_version,
|
||||
)
|
||||
orphaned = [tag for tag in newer_tags if tag not in releases_by_tag]
|
||||
unadvertised = [
|
||||
tag
|
||||
for tag in newer_tags
|
||||
if tag in releases_by_tag
|
||||
and releases_by_tag[tag].get("draft") is False
|
||||
and isinstance(releases_by_tag[tag].get("published_at"), str)
|
||||
and bool(releases_by_tag[tag]["published_at"])
|
||||
]
|
||||
registry_tags: dict[str, set[str]] = {}
|
||||
for registry in registry_payloads:
|
||||
if (
|
||||
not isinstance(registry, dict)
|
||||
or not isinstance(registry.get("name"), str)
|
||||
or not isinstance(registry.get("tags"), list)
|
||||
or not all(isinstance(tag, str) for tag in registry["tags"])
|
||||
):
|
||||
return (
|
||||
[
|
||||
violation(
|
||||
"frontier_payload_invalid",
|
||||
"registries",
|
||||
"array of registry tag-list objects",
|
||||
registry,
|
||||
FRONTIER_RULES,
|
||||
)
|
||||
],
|
||||
identity,
|
||||
)
|
||||
registry_name = registry["name"]
|
||||
for raw_tag in registry["tags"]:
|
||||
tag = raw_tag if raw_tag.startswith("v") else f"v{raw_tag}"
|
||||
if STABLE_TAG.fullmatch(tag) and stable_version(tag) > latest_version:
|
||||
registry_tags.setdefault(tag, set()).add(registry_name)
|
||||
identity.update(
|
||||
{
|
||||
"newer_stable_tags": ",".join(newer_tags),
|
||||
"orphaned_stable_tags": ",".join(orphaned),
|
||||
"unadvertised_published_stable_tags": ",".join(unadvertised),
|
||||
"registry_stable_tags_beyond_latest": ",".join(
|
||||
sorted(registry_tags, key=stable_version)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
failures = [
|
||||
violation(
|
||||
"stable_tag_without_release",
|
||||
f"refs/tags/{tag}",
|
||||
"one GitHub Release object",
|
||||
None,
|
||||
FRONTIER_RULES,
|
||||
)
|
||||
for tag in orphaned
|
||||
]
|
||||
failures.extend(
|
||||
violation(
|
||||
"newer_stable_release_not_advertised",
|
||||
f"releases/{tag}",
|
||||
f"not newer than {latest_tag}",
|
||||
tag,
|
||||
FRONTIER_RULES,
|
||||
)
|
||||
for tag in unadvertised
|
||||
)
|
||||
failures.extend(
|
||||
violation(
|
||||
"registry_stable_tag_beyond_latest",
|
||||
f"registries/{tag}",
|
||||
f"no public stable container tag newer than {latest_tag}",
|
||||
",".join(sorted(registry_tags[tag])),
|
||||
FRONTIER_RULES,
|
||||
)
|
||||
for tag in sorted(registry_tags, key=stable_version)
|
||||
)
|
||||
return failures, identity
|
||||
|
||||
|
||||
def activation_violations(
|
||||
payload: Any, expected_release: dict[str, Any], activation_bytes: bytes
|
||||
) -> list[Violation]:
|
||||
@@ -541,6 +745,29 @@ def validate_activation(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def validate_frontier(args: argparse.Namespace) -> int:
|
||||
try:
|
||||
latest_release = read_json(args.release_json)
|
||||
stable_refs = read_json(args.stable_refs_json)
|
||||
releases = read_json(args.releases_json)
|
||||
registries = [read_json(path) for path in args.registry_tags_json]
|
||||
except ValueError as exc:
|
||||
failures = [
|
||||
violation(
|
||||
"frontier_payload_invalid", "$", "valid JSON inventories", str(exc), FRONTIER_RULES
|
||||
)
|
||||
]
|
||||
identity: dict[str, Any] = {}
|
||||
else:
|
||||
failures, identity = frontier_violations(latest_release, stable_refs, releases, registries)
|
||||
|
||||
write_diagnostic(args.diagnostic, "stable_publication_frontier", identity, failures)
|
||||
if failures:
|
||||
report_failures(failures, FRONTIER_RULES)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
@@ -555,6 +782,13 @@ def parse_args() -> argparse.Namespace:
|
||||
activation.add_argument("--activation-json", type=Path, required=True)
|
||||
activation.add_argument("--diagnostic", type=Path, required=True)
|
||||
activation.add_argument("--github-output", type=Path, required=True)
|
||||
|
||||
frontier = commands.add_parser("frontier")
|
||||
frontier.add_argument("--release-json", type=Path, required=True)
|
||||
frontier.add_argument("--stable-refs-json", type=Path, required=True)
|
||||
frontier.add_argument("--releases-json", type=Path, required=True)
|
||||
frontier.add_argument("--registry-tags-json", type=Path, action="append", required=True)
|
||||
frontier.add_argument("--diagnostic", type=Path, required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -562,7 +796,9 @@ def main() -> int:
|
||||
args = parse_args()
|
||||
if args.command == "release":
|
||||
return validate_release(args)
|
||||
return validate_activation(args)
|
||||
if args.command == "activation":
|
||||
return validate_activation(args)
|
||||
return validate_frontier(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -67,6 +67,9 @@ class ReleaseContinuityTest(unittest.TestCase):
|
||||
command: str,
|
||||
release: object,
|
||||
activation: object | None = None,
|
||||
stable_refs: object | None = None,
|
||||
releases: object | None = None,
|
||||
registries: list[object] | None = None,
|
||||
) -> tuple[subprocess.CompletedProcess[str], dict[str, object], str]:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
@@ -83,13 +86,32 @@ class ReleaseContinuityTest(unittest.TestCase):
|
||||
str(release_path),
|
||||
"--diagnostic",
|
||||
str(diagnostic),
|
||||
"--github-output",
|
||||
str(output),
|
||||
]
|
||||
if command != "frontier":
|
||||
args.extend(["--github-output", str(output)])
|
||||
if command == "activation":
|
||||
activation_path = root / "activation.json"
|
||||
activation_path.write_bytes(encoded(activation))
|
||||
args.extend(["--activation-json", str(activation_path)])
|
||||
if command == "frontier":
|
||||
stable_refs_path = root / "stable-refs.json"
|
||||
releases_path = root / "releases.json"
|
||||
stable_refs_path.write_text(json.dumps(stable_refs), encoding="utf-8")
|
||||
releases_path.write_text(json.dumps(releases), encoding="utf-8")
|
||||
args.extend(
|
||||
[
|
||||
"--stable-refs-json",
|
||||
str(stable_refs_path),
|
||||
"--releases-json",
|
||||
str(releases_path),
|
||||
]
|
||||
)
|
||||
for index, registry in enumerate(
|
||||
registries or [{"name": "registry.example/pulse", "tags": []}]
|
||||
):
|
||||
registry_path = root / f"registry-{index}.json"
|
||||
registry_path.write_text(json.dumps(registry), encoding="utf-8")
|
||||
args.extend(["--registry-tags-json", str(registry_path)])
|
||||
result = subprocess.run(
|
||||
args,
|
||||
cwd=ROOT,
|
||||
@@ -272,6 +294,102 @@ class ReleaseContinuityTest(unittest.TestCase):
|
||||
],
|
||||
)
|
||||
|
||||
def test_frontier_accepts_tags_at_or_behind_advertised_release(self) -> None:
|
||||
release = valid_release()
|
||||
result, diagnostic, output = self.run_command(
|
||||
"frontier",
|
||||
release,
|
||||
stable_refs=[
|
||||
{"ref": "refs/tags/v6.4.1"},
|
||||
{"ref": "refs/tags/v6.4.2"},
|
||||
{"ref": "refs/tags/v6.4.3-rc.1"},
|
||||
],
|
||||
releases=[release],
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(diagnostic["status"], "success")
|
||||
self.assertEqual(diagnostic["identity"]["newer_stable_tags"], "")
|
||||
self.assertEqual(output, "")
|
||||
|
||||
def test_frontier_rejects_newer_stable_tag_without_release_packet(self) -> None:
|
||||
release = valid_release()
|
||||
result, diagnostic, _ = self.run_command(
|
||||
"frontier",
|
||||
release,
|
||||
stable_refs=[[{"ref": "refs/tags/v6.4.2"}, {"ref": "refs/tags/v6.4.3"}]],
|
||||
releases=[[release]],
|
||||
)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertEqual(
|
||||
[item["code"] for item in diagnostic["violations"]],
|
||||
["stable_tag_without_release"],
|
||||
)
|
||||
self.assertEqual(diagnostic["identity"]["orphaned_stable_tags"], "v6.4.3")
|
||||
self.assertIn("orphaned version", result.stderr)
|
||||
|
||||
def test_frontier_rejects_public_registry_version_beyond_latest(self) -> None:
|
||||
release = valid_release()
|
||||
result, diagnostic, _ = self.run_command(
|
||||
"frontier",
|
||||
release,
|
||||
stable_refs=[{"ref": "refs/tags/v6.4.2"}],
|
||||
releases=[release],
|
||||
registries=[
|
||||
{
|
||||
"name": "docker.io/rcourtman/pulse",
|
||||
"tags": ["latest", "6.4", "6.4.2", "v6.4.3"],
|
||||
},
|
||||
{
|
||||
"name": "ghcr.io/rcourtman/pulse",
|
||||
"tags": ["v6.4.3", "6.4.3-rc.1"],
|
||||
},
|
||||
],
|
||||
)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertEqual(
|
||||
[item["code"] for item in diagnostic["violations"]],
|
||||
["registry_stable_tag_beyond_latest"],
|
||||
)
|
||||
self.assertEqual(
|
||||
diagnostic["identity"]["registry_stable_tags_beyond_latest"],
|
||||
"v6.4.3",
|
||||
)
|
||||
self.assertIn("docker.io/rcourtman/pulse", diagnostic["violations"][0]["actual"])
|
||||
self.assertIn("ghcr.io/rcourtman/pulse", diagnostic["violations"][0]["actual"])
|
||||
|
||||
def test_frontier_rejects_published_stable_release_beyond_latest(self) -> None:
|
||||
release = valid_release()
|
||||
newer = {**release, "id": 67890, "tag_name": "v6.4.3"}
|
||||
result, diagnostic, _ = self.run_command(
|
||||
"frontier",
|
||||
release,
|
||||
stable_refs=[{"ref": "refs/tags/v6.4.3"}],
|
||||
releases=[release, newer],
|
||||
)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertEqual(
|
||||
[item["code"] for item in diagnostic["violations"]],
|
||||
["newer_stable_release_not_advertised"],
|
||||
)
|
||||
|
||||
def test_frontier_allows_in_progress_draft_beyond_latest(self) -> None:
|
||||
release = valid_release()
|
||||
draft = {
|
||||
**release,
|
||||
"id": 67890,
|
||||
"tag_name": "v6.4.3",
|
||||
"draft": True,
|
||||
"published_at": None,
|
||||
}
|
||||
result, diagnostic, _ = self.run_command(
|
||||
"frontier",
|
||||
release,
|
||||
stable_refs=[{"ref": "refs/tags/v6.4.3"}],
|
||||
releases=[release, draft],
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(diagnostic["status"], "success")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user