ci: prepare exact RC1 distribution delivery #27

Merged
KofTwentyTwo merged 2 commits from feature/GH-921-qqq-all-rc-path into develop 2026-10-02 01:24:39 +00:00
11 changed files with 608 additions and 40 deletions

No files matched your search

+68 -13
View File
@@ -3,8 +3,16 @@ name: Release 4.1.0
on:
workflow_dispatch:
inputs:
version:
description: Reviewed source version (GA or exact coordinated RC)
required: true
type: choice
default: '4.1.0'
options:
- '4.1.0'
- '4.1.0-RC.1'
release_sha:
description: Full develop commit SHA already tagged 4.1.0
description: Full develop commit SHA already signed and tagged with the selected version
required: true
type: string
smoke_run_id:
@@ -32,8 +40,8 @@ jobs:
SMOKE_RUN_ID: ${{ inputs.smoke_run_id }}
RELEASE_ENABLED: ${{ vars.QQQ_ALL_RELEASE_ENABLED }}
LICENSE_CLEARED: ${{ vars.QQQ_ALL_RELEASE_LICENSE_APPROVED }}
VERSION: 4.1.0
TAG: 4.1.0
VERSION: ${{ inputs.version }}
TAG: ${{ inputs.version }}
steps:
- uses: actions/checkout@v4
with:
@@ -46,19 +54,21 @@ jobs:
test "$GITHUB_REF" = refs/heads/develop
test "$RELEASE_ENABLED" = true
test "$LICENSE_CLEARED" = true
case "$VERSION" in 4.1.0|4.1.0-RC.1) ;; *) exit 1 ;; esac
[[ "$RELEASE_SHA" =~ ^[0-9a-f]{40}$ ]]
[[ "$SMOKE_RUN_ID" =~ ^[0-9]+$ ]]
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
test "$RELEASE_SHA" = "$GITHUB_SHA"
test "$(git cat-file -t "refs/tags/$TAG")" = tag
test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$RELEASE_SHA"
- name: Verify upstream GA, exact-commit smoke, Central POMs, and public GHCR
gh api "repos/$GITHUB_REPOSITORY/git/tags/$(git rev-parse "refs/tags/$TAG")" > "$RUNNER_TEMP/tag-object.json"
- name: Verify upstream release, signed tag, exact-commit smoke, public pins, and public GHCR
shell: bash
run: |
set -euo pipefail
ga_state="$(gh api repos/QRun-IO/qqq/releases/tags/v4.1.0 \
--jq 'if (.tag_name == "v4.1.0" and .draft == false and .prerelease == false) then "ready" else "blocked" end')"
test "$ga_state" = ready
gh api "repos/QRun-IO/qqq/releases/tags/v$VERSION" > "$RUNNER_TEMP/upstream-release.json"
next_version="$(python3 -c 'import sys; sys.path.insert(0, "scripts"); from release_gate import properties, validate_poms; from pathlib import Path; import os; validate_poms(Path("."), os.environ["VERSION"]); print(properties(Path("qqq-all-bom/pom.xml"))["qqq-frontend-next.version"])')"
gh api "repos/QRun-IO/qqq-frontend-next/releases/tags/v$next_version" > "$RUNNER_TEMP/next-release.json"
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$SMOKE_RUN_ID" > "$RUNNER_TEMP/smoke-run.json"
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$SMOKE_RUN_ID/jobs?per_page=100" > "$RUNNER_TEMP/smoke-jobs.json"
central="https://repo.maven.apache.org/maven2/com/kingsrook/qqq"
@@ -71,7 +81,12 @@ jobs:
--smoke-run "$RUNNER_TEMP/smoke-run.json" \
--smoke-jobs "$RUNNER_TEMP/smoke-jobs.json" \
--published-parent "$RUNNER_TEMP/qqq-all-parent.pom" \
--published-bom "$RUNNER_TEMP/qqq-all-bom.pom"
--published-bom "$RUNNER_TEMP/qqq-all-bom.pom" \
--upstream-release "$RUNNER_TEMP/upstream-release.json" \
--next-release "$RUNNER_TEMP/next-release.json" \
--tag-object "$RUNNER_TEMP/tag-object.json" \
--tag-sha "$(git rev-parse "refs/tags/$TAG")" \
--public-inputs "$RUNNER_TEMP/public-inputs"
test "$(gh api /orgs/QRun-IO/packages/container/qqq-all --jq .visibility)" = public
gh api --paginate 'orgs/QRun-IO/packages/container/qqq-all/versions?per_page=100' \
--jq '.[] | .metadata.container.tags[]?' > "$RUNNER_TEMP/ghcr-tags.txt"
@@ -85,12 +100,20 @@ jobs:
java-version: '21'
cache: maven
- name: Verify release reactor
run: mvn -B -ntp verify
shell: bash
run: |
set -euo pipefail
printf '<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"/>\n' > "$RUNNER_TEMP/public-settings.xml"
python3 -m unittest discover -s scripts -p 'test_*.py' -v
mvn -B -ntp -s "$RUNNER_TEMP/public-settings.xml" -gs "$RUNNER_TEMP/public-settings.xml" \
-Dmaven.repo.local="$RUNNER_TEMP/release-m2" verify \
org.apache.maven.plugins:maven-enforcer-plugin:3.5.0:enforce -Drules=requireReleaseDeps
- name: Recheck develop, annotated tag, and release absence before publication
shell: bash
run: |
set -euo pipefail
test "$(gh api "repos/$GITHUB_REPOSITORY/branches/develop" --jq .commit.sha)" = "$RELEASE_SHA"
test "$(git ls-remote origin "refs/tags/$TAG" | cut -f1)" = "$(git rev-parse "refs/tags/$TAG")"
test "$(git ls-remote origin "refs/tags/$TAG^{}" | cut -f1)" = "$RELEASE_SHA"
if release_check="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$TAG" 2>&1)"; then
echo "GitHub release $TAG already exists" >&2
@@ -99,6 +122,12 @@ jobs:
echo "$release_check" >&2
exit 1
fi
gh api --paginate 'orgs/QRun-IO/packages/container/qqq-all/versions?per_page=100' \
--jq '.[] | .metadata.container.tags[]?' > "$RUNNER_TEMP/ghcr-tags-final.txt"
if grep -Fxq "$VERSION" "$RUNNER_TEMP/ghcr-tags-final.txt"; then
echo "GHCR tag $VERSION already exists" >&2
exit 1
fi
- uses: docker/login-action@v3
with:
registry: ghcr.io
@@ -115,10 +144,36 @@ jobs:
--label "org.opencontainers.image.revision=$RELEASE_SHA" \
--tag "$image" .
docker push "$image"
printf 'Published image: `%s`\n' "$image" >> "$GITHUB_STEP_SUMMARY"
- name: Publish GitHub release with Compose asset
printf 'Published image: %s\n' "$image" >> "$GITHUB_STEP_SUMMARY"
- name: Build image Compose distribution bundle
shell: bash
run: |
set -euo pipefail
gh release create "$TAG" compose.yaml --repo "$GITHUB_REPOSITORY" \
--verify-tag --title "qqq-all $VERSION" --notes-from-tag
image="$(docker image inspect --format '{{index .RepoDigests 0}}' "ghcr.io/qrun-io/qqq-all:$VERSION")"
python3 scripts/release_bundle.py --root . --version "$VERSION" --sha "$RELEASE_SHA" \
--image "$image" --output "$RUNNER_TEMP"
- name: Publish GitHub release with Compose bundle
shell: bash
run: |
set -euo pipefail
release_flags=()
case "$VERSION" in
4.1.0) ;;
4.1.0-RC.1) release_flags=(--prerelease --latest=false) ;;
*) exit 1 ;;
esac
gh release create "$TAG" "$RUNNER_TEMP/qqq-all-$VERSION.tar.gz" \
"$RUNNER_TEMP/qqq-all-$VERSION/compose.yaml" --repo "$GITHUB_REPOSITORY" \
--verify-tag --title "qqq-all $VERSION" --notes-from-tag "${release_flags[@]}"
- uses: actions/upload-artifact@v4
if: always()
with:
name: release-evidence-${{ inputs.version }}
path: |
${{ runner.temp }}/public-inputs/receipts.json
${{ runner.temp }}/tag-object.json
${{ runner.temp }}/upstream-release.json
${{ runner.temp }}/next-release.json
${{ runner.temp }}/smoke-run.json
${{ runner.temp }}/smoke-jobs.json
${{ runner.temp }}/qqq-all-${{ inputs.version }}/release.json
+3 -1
View File
@@ -181,7 +181,9 @@ services:
profiles: [full]
command: "${SFTP_USER:-qqq}:${SFTP_PASSWORD:?set SFTP_PASSWORD}:1001:1001:upload"
volumes:
- sftp-data:/home/${SFTP_USER:-qqq}/upload
- type: volume
source: sftp-data
target: /home/${SFTP_USER:-qqq}/upload
healthcheck:
test: ["CMD-SHELL", "pgrep sshd >/dev/null"]
interval: 5s
+15 -3
View File
@@ -1,6 +1,18 @@
# 4.1.0 release preparation
This procedure prepares a release; this repository currently remains on `4.1.0-SNAPSHOT`. The [manual release workflow](../.github/workflows/release.yml) only publishes the versioned GHCR image and a GitHub release containing `compose.yaml`. It does **not** deploy Maven artifacts or create a tag. Publication is authorized once the release gates pass. Keep the workflow disabled until Apache-2.0 alignment, public artifact verification and GHCR visibility are complete.
This procedure prepares a release; this repository currently remains on `4.1.0-SNAPSHOT`. The [manual release workflow](../.github/workflows/release.yml) publishes the versioned GHCR image and a GitHub release containing an image-based Compose bundle plus `compose.yaml`. It does **not** deploy Maven artifacts or create a tag. Publication is authorized once the release gates pass. Keep the workflow disabled until Apache-2.0 alignment, public artifact verification and GHCR visibility are complete.
## Exact coordinated RC under QQQ #921
The same workflow accepts only `4.1.0` (default GA path) or exact `4.1.0-RC.1`. For the [coordinated RC chain](https://github.com/QRun-IO/qqq/issues/921), first publish and verify the accepted Next candidate, QQQ `v4.1.0-RC.1`, and the corresponding build parent/eight QBits. Then review actual source pins: root `revision` and BOM `qqq.version` both `4.1.0-RC.1`, explicit accepted Next version, and immutable published QBit versions. No command-line version overrides substitute for source edits. The current RC8/snapshot pins are intentionally unchanged and cannot pass release validation. Acceptance limitations belong to the exact accepted pair; the RC8 notes below are historical evidence, not automatic approval for a newer pair.
Use the existing `release-central` profile and the parent/BOM-only command below to stage the exact RC POMs for **manual** Central review (`autoPublish=false`). Its enforcers accept only GA or exact RC1, require the QQQ pin to equal the source revision, and reject snapshot/range/LATEST dependency pins. After both POMs are public and the reviewed source is merged, require successful push `Smoke` core/full on that exact develop SHA and an annotated signed tag named `4.1.0-RC.1` at it. The workflow requires GitHub's tag verification to report `verified=true`, `reason=valid`, and checks the exact local/remote tag object and commit. Public QQQ/Next release tags must match their pins and prerelease state; first-party BOM inputs (including recursive public parents) must resolve anonymously, with qbit-build-parent importing this QQQ version. The full reactor also rejects transitive snapshot dependencies. Public POM comparison includes the explicit Next pin. Existing GHCR visibility, protected environment, license/enable controls, destination-absence checks and live ref rechecks remain required.
When those gates and owner authorization are satisfied, dispatch **Release 4.1.0** on develop with `version=4.1.0-RC.1`, the full `release_sha`, and its successful push `smoke_run_id`. RC publication uses `--prerelease --latest=false`; it never becomes the latest GA release. The default `version=4.1.0` still requires upstream non-prerelease GA and follows the separate GA requirements below. Do not dispatch while any pin or external prerequisite is pending.
## Image Compose bundle
The workflow attaches `qqq-all-<version>.tar.gz` and its rendered `compose.yaml`. Download/extract the **whole bundle**, retain the `infra` directory beside Compose, and follow its README. Both app profiles use the exact pushed GHCR digest and have no app build context. The bundle includes the tracked PostgreSQL/MySQL/Mongo initialization, Keycloak realm, MinIO Dockerfile, `.env.example`, LICENSE/NOTICE and `release.json` source/image identity. It excludes local `.env`, application source and Maven outputs. Full-profile MinIO still builds locally and needs network access; other service images are unchanged. The standalone Compose asset alone is insufficient for full-profile operation. Configure a unique Compose project and demo credentials, build only MinIO for full, then start with `--no-build`. Verify anonymous image pull and real core/full startup from the extracted bundle after publication; config-rendering tests are not runtime acceptance.
## Next RC8 integration
@@ -25,10 +37,10 @@ The owner selected Apache-2.0 for first-party code in [QQQ #923](https://github.
## Exact commit and manual dispatch
After the release POMs are public, merge the reviewed version-pin change and the C7 smoke workflow to `develop`. Require a successful **push** run of `Smoke` on the exact intended commit; both `core` and `full` jobs must pass. Review the run's full SHA and run ID in Actions. Create an **annotated** `4.1.0` tag at that exact `develop` commit through the normal signed-tag process. The workflow checks that the local tag is annotated and that the remote tag resolves to the same SHA; it does not verify a tag signature, so the operator must do that separately. Do not move the tag or advance `develop` before dispatch.
After the release POMs are public, merge the reviewed version-pin change and the C7 smoke workflow to `develop`. Require a successful **push** run of `Smoke` on the exact intended commit; both `core` and `full` jobs must pass. Review the run's full SHA and run ID in Actions. Create an **annotated**, signed `4.1.0` tag at that exact `develop` commit. The workflow checks GitHub's valid signature verification, the tag object and its exact commit, including live remote refs. Do not move the tag or advance `develop` before dispatch.
Configure a protected `qqq-all-release` GitHub environment with required reviewers and branch restriction to `develop`. After Apache alignment and the publication preconditions pass, set environment variables `QQQ_ALL_RELEASE_LICENSE_APPROVED=true` and `QQQ_ALL_RELEASE_ENABLED=true`. Unset them to close the gate. These variables and the environment approval are operational controls, not substitutes for the release review.
Dispatch **Release 4.1.0** on `develop` with `release_sha` (the full 40-character commit SHA) and `smoke_run_id` (the successful push run ID). The job requires that checkout, `develop`, the annotated tag, and the smoke run all refer to that SHA. It checks upstream GA, both public Central POMs, public GHCR visibility, and the absence of a `4.1.0` GHCR tag; runs `mvn -B -ntp verify`; then rechecks the live `develop` and remote tag refs before publication. Only then does it build and push `ghcr.io/qrun-io/qqq-all:4.1.0` and create the GitHub `4.1.0` release with `compose.yaml` attached.
Dispatch **Release 4.1.0** on `develop` with `version=4.1.0` (default), `release_sha` (the full 40-character commit SHA) and `smoke_run_id` (the successful push run ID). The job requires that checkout, `develop`, the annotated signed tag, and the smoke run all refer to that SHA. It checks upstream GA, both public Central POMs and immutable upstream inputs, public GHCR visibility, and destination absence; verifies the reactor with a fresh public-only cache and rejects transitive snapshots; then rechecks the live `develop` and remote tag refs before publication. Only then does it build and push `ghcr.io/qrun-io/qqq-all:4.1.0` and create the GitHub `4.1.0` release with the Compose bundle and rendered `compose.yaml` attached.
The image push and GitHub release creation are separate API operations, not an atomic transaction. If the release step fails after the image push, inspect the image digest and workflow logs before recovery; never reuse or overwrite the version tag blindly. Verify the public image pull, release asset, and both Maven POMs from an unauthenticated environment after publication. No workflow in this preparation branch publishes or tags anything by itself.
+3 -3
View File
@@ -62,15 +62,15 @@
<version>3.5.0</version>
<executions>
<execution>
<id>require-ga-revision</id>
<id>require-release-revision</id>
<phase>validate</phase>
<goals><goal>enforce</goal></goals>
<configuration>
<rules>
<requireProperty>
<property>revision</property>
<regex>^4[.]1[.]0$</regex>
<message>Central release requires a reviewed 4.1.0 source revision.</message>
<regex>^4[.]1[.]0(-RC[.]1)?$</regex>
<message>Central release requires reviewed 4.1.0 or exact 4.1.0-RC.1 source pins.</message>
</requireProperty>
<requireReleaseDeps/>
</rules>
+10 -9
View File
@@ -56,16 +56,17 @@
<rules>
<requireProperty>
<property>qqq.version</property>
<regex>^4[.]1[.]0$</regex>
<regex>^\Q${revision}\E$</regex>
</requireProperty>
<requireProperty><property>qbit-quick-search.version</property><regex>^(?!.*SNAPSHOT).+$</regex></requireProperty>
<requireProperty><property>qbit-user-role-permissions.version</property><regex>^(?!.*SNAPSHOT).+$</regex></requireProperty>
<requireProperty><property>qbit-customizable-table-views.version</property><regex>^(?!.*SNAPSHOT).+$</regex></requireProperty>
<requireProperty><property>qbit-standard-process-trace.version</property><regex>^(?!.*SNAPSHOT).+$</regex></requireProperty>
<requireProperty><property>qbit-webhooks.version</property><regex>^(?!.*SNAPSHOT).+$</regex></requireProperty>
<requireProperty><property>qbit-workflows.version</property><regex>^(?!.*SNAPSHOT).+$</regex></requireProperty>
<requireProperty><property>qbit-geo-data.version</property><regex>^(?!.*SNAPSHOT).+$</regex></requireProperty>
<requireProperty><property>qbit-sftp-data-integration.version</property><regex>^(?!.*SNAPSHOT).+$</regex></requireProperty>
<requireProperty><property>qqq-frontend-next.version</property><regex>^[0-9]+[.][0-9]+[.][0-9]+(-RC[.][1-9][0-9]*)?$</regex></requireProperty>
<requireProperty><property>qbit-quick-search.version</property><regex>^[0-9]+[.][0-9]+[.][0-9]+(-RC[.][1-9][0-9]*)?$</regex></requireProperty>
<requireProperty><property>qbit-user-role-permissions.version</property><regex>^[0-9]+[.][0-9]+[.][0-9]+(-RC[.][1-9][0-9]*)?$</regex></requireProperty>
<requireProperty><property>qbit-customizable-table-views.version</property><regex>^[0-9]+[.][0-9]+[.][0-9]+(-RC[.][1-9][0-9]*)?$</regex></requireProperty>
<requireProperty><property>qbit-standard-process-trace.version</property><regex>^[0-9]+[.][0-9]+[.][0-9]+(-RC[.][1-9][0-9]*)?$</regex></requireProperty>
<requireProperty><property>qbit-webhooks.version</property><regex>^[0-9]+[.][0-9]+[.][0-9]+(-RC[.][1-9][0-9]*)?$</regex></requireProperty>
<requireProperty><property>qbit-workflows.version</property><regex>^[0-9]+[.][0-9]+[.][0-9]+(-RC[.][1-9][0-9]*)?$</regex></requireProperty>
<requireProperty><property>qbit-geo-data.version</property><regex>^[0-9]+[.][0-9]+[.][0-9]+(-RC[.][1-9][0-9]*)?$</regex></requireProperty>
<requireProperty><property>qbit-sftp-data-integration.version</property><regex>^[0-9]+[.][0-9]+[.][0-9]+(-RC[.][1-9][0-9]*)?$</regex></requireProperty>
</rules>
</configuration>
</execution>
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Package digest-pinned Compose with the tracked full-profile infrastructure."""
import argparse
import json
import os
import re
import shutil
import subprocess
import tarfile
from pathlib import Path
from release_gate import validate_version
def create_bundle(root, version, sha, image, output):
validate_version(version)
if not re.fullmatch(r"[0-9a-f]{40}", sha):
raise ValueError("Source SHA must be a full commit ID")
if not re.fullmatch(r"ghcr\.io/qrun-io/qqq-all@sha256:[0-9a-f]{64}", image):
raise ValueError("Release image must be the qqq-all GHCR digest")
environment = {key: value for key, value in os.environ.items() if not key.startswith("COMPOSE_")}
rendered = subprocess.run(
["docker", "compose", "--env-file", ".env.example", "--profile", "*", "-f", "compose.yaml",
"config", "--no-interpolate", "--no-env-resolution", "--no-path-resolution", "--format", "json"],
cwd=root, env=environment, capture_output=True, text=True, check=True,
)
compose = json.loads(rendered.stdout)
compose.pop("x-app-image", None)
for name in ("app-core", "app-full"):
app = compose["services"][name]
app.pop("build", None)
app["image"] = image
app["pull_policy"] = "always"
compose["services"]["minio"]["image"] = "qqq-all-minio:" + version
name = "qqq-all-" + version
bundle = output / name
bundle.mkdir(parents=True, exist_ok=False)
(bundle / "compose.yaml").write_text(json.dumps(compose, indent=2) + "\n")
tracked = subprocess.check_output(["git", "-C", str(root), "ls-files", "--", "infra"], text=True).splitlines()
for filename in (".env.example", "LICENSE", "NOTICE", *tracked):
source = root / filename
if source.is_symlink() or not source.is_file():
raise ValueError(f"Bundle input must be a regular file: {filename}")
target = bundle / filename
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
(bundle / "release.json").write_text(json.dumps(
{"version": version, "source_sha": sha, "image": image}, indent=2) + "\n")
(bundle / "README.md").write_text(f"""# qqq-all {version}
Extract this complete bundle and run commands from this directory. The application uses
`{image}` (source `{sha}`). No application source, Maven or app build is required.
Docker with Compose v2 is required. Copy `.env.example` to `.env` and replace
the demonstration passwords before sharing the environment. All fixture values remain
demo-only; retain the infra directory next to compose.yaml.
```bash
cp .env.example .env
docker compose -p qqq-all-demo --profile core up -d --wait --no-build
```
For the full profile, stop core first. The included MinIO Dockerfile still builds locally
and needs network access; PostgreSQL/MySQL/Mongo initialization and the Keycloak realm
use the bundled infra files. External service images also need network access.
```bash
docker compose -p qqq-all-demo --profile core down
docker compose -p qqq-all-demo --profile full build minio
docker compose -p qqq-all-demo --profile full up -d --wait --no-build
```
Use a distinct Compose project name for each installation. The application listens on
http://localhost:8080 by default. The full-profile identity provider uses
http://keycloak.localhost:8081; that hostname must resolve to loopback on the client.
Ports and fixture settings are in `.env.example`. `docker compose ... down` retains
named data volumes; remove volumes only when intentionally resetting that installation.
The standalone compose.yaml asset needs this bundle's infra and environment template
for full-profile operation. See release.json for the exact application identity.
""")
archive = output / (name + ".tar.gz")
with tarfile.open(archive, "x:gz") as handle:
handle.add(bundle, arcname=name)
return archive
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, required=True)
parser.add_argument("--version", required=True)
parser.add_argument("--sha", required=True)
parser.add_argument("--image", required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
print(create_bundle(args.root.resolve(), args.version, args.sha, args.image, args.output.resolve()))
if __name__ == "__main__":
main()
+124 -11
View File
@@ -1,9 +1,12 @@
"""Fail-closed checks for the manual 4.1.0 release workflow."""
"""Fail-closed checks for the manual 4.1.0 GA / exact RC.1 release workflow."""
import argparse
import hashlib
import json
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path
from xml.etree import ElementTree
@@ -13,6 +16,16 @@ QBITS = (
"standard-process-trace", "webhooks", "workflows", "geo-data",
"sftp-data-integration",
)
RELEASE_VERSIONS = ("4.1.0", "4.1.0-RC.1")
def immutable_version(value):
return re.fullmatch(r"[0-9]+(?:\.[0-9]+){2,3}(?:-RC\.[1-9][0-9]*)?", value) is not None
def validate_version(version):
if version not in RELEASE_VERSIONS:
raise GateError("This release workflow is limited to 4.1.0 and 4.1.0-RC.1")
class GateError(ValueError):
@@ -64,7 +77,7 @@ def managed_dependencies(path):
reference = re.fullmatch(r"\$\{([^}]+)\}", version)
if reference:
version = values.get(reference.group(1), "")
if not version or "${" in version or "SNAPSHOT" in version.upper():
if not immutable_version(version):
raise GateError(f"Unreleased managed dependency in {path}: "
f"{field(dependency, 'artifactId')}")
managed.append((field(dependency, "groupId"), field(dependency, "artifactId"),
@@ -84,6 +97,9 @@ def validate_managed_bom(path, version):
if entries.get(("com.kingsrook.qqq", "qqq-bom-pom")) != (version, "pom", "import"):
raise GateError(f"QQQ BOM import differs from {version} in {path}")
values = properties(path)
if entries.get(("com.kingsrook.qqq", "qqq-frontend-next")) != (
values.get("qqq-frontend-next.version"), "jar", "compile"):
raise GateError(f"Managed Next differs from its release pin in {path}")
for name in QBITS:
artifact = f"qbit-{name}"
if entries.get(("com.kingsrook.qbits", artifact)) != (
@@ -102,14 +118,14 @@ def validate_published_poms(parent_path, bom_path, version, local_bom):
raise GateError(f"Published {artifact} POM is not the {version} release")
published = properties(bom_path)
if published.get("qqq.version") != version:
raise GateError("Published BOM does not import QQQ 4.1.0")
raise GateError(f"Published BOM does not import QQQ {version}")
for name in QBITS:
key = f"qbit-{name}.version"
value = published.get(key, "")
if not value or "SNAPSHOT" in value.upper():
if not immutable_version(value):
raise GateError(f"Published BOM {key} is not a release")
local = properties(local_bom)
for key in ("qqq.version", *(f"qbit-{name}.version" for name in QBITS)):
for key in ("qqq.version", "qqq-frontend-next.version", *(f"qbit-{name}.version" for name in QBITS)):
if published.get(key) != local.get(key):
raise GateError(f"Published BOM {key} differs from release source")
if validate_managed_bom(bom_path, version) != validate_managed_bom(local_bom, version):
@@ -117,20 +133,19 @@ def validate_published_poms(parent_path, bom_path, version, local_bom):
def validate_poms(root, version):
if version != "4.1.0":
raise GateError("This release workflow is limited to 4.1.0")
validate_version(version)
parent = properties(root / "pom.xml")
bom = properties(root / "qqq-all-bom/pom.xml")
if parent.get("revision") != version or bom.get("qqq.version") != version:
raise GateError("Parent revision and QQQ BOM pin must both equal 4.1.0")
raise GateError(f"Parent revision and QQQ BOM pin must both equal {version}")
for name in QBITS:
key = f"qbit-{name}.version"
value = bom.get(key, "")
if not value or "SNAPSHOT" in value.upper():
if not immutable_version(value):
raise GateError(f"{key} must be a published non-snapshot version")
for key, value in bom.items():
if key.endswith(".version") and "SNAPSHOT" in value.upper():
raise GateError(f"{key} still points to a snapshot")
if key.endswith(".version") and not immutable_version(value):
raise GateError(f"{key} must be an immutable release version")
validate_managed_bom(root / "qqq-all-bom/pom.xml", version)
@@ -156,6 +171,94 @@ def validate_smoke(run, jobs, sha):
raise GateError("Smoke run must include successful core and full jobs")
def validate_upstream_release(release, version):
if (not immutable_version(version) or release.get("tag_name") != "v" + version
or release.get("draft") is not False
or release.get("prerelease") is not ("-RC." in version)):
raise GateError(f"Upstream release must be public v{version} with matching prerelease state")
def validate_tag(tag, version, sha, tag_sha):
validate_version(version)
if not all(re.fullmatch(r"[0-9a-f]{40}", value) for value in (sha, tag_sha)):
raise GateError("Tag and commit IDs must be full Git SHAs")
if (tag.get("sha") != tag_sha or tag.get("tag") != version
or tag.get("object", {}).get("type") != "commit"
or tag.get("object", {}).get("sha") != sha
or tag.get("verification", {}).get("verified") is not True
or tag.get("verification", {}).get("reason") != "valid"):
raise GateError("Release tag must be signed, verified, annotated and point to the exact release commit")
def download_public_inputs(bom, version, destination, central="https://repo.maven.apache.org/maven2"):
"""Fetch the declared first-party inputs and their parents without credentials."""
validate_version(version)
destination.mkdir(parents=True, exist_ok=True)
receipts = []
visited = set()
def fetch(group, artifact, pin, kind):
coordinate = (group, artifact, pin, kind)
if coordinate in visited:
return
if (not re.fullmatch(r"[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*", group)
or not re.fullmatch(r"[A-Za-z0-9_-]+", artifact) or not immutable_version(pin)):
raise GateError(f"Invalid or mutable public coordinate: {coordinate}")
visited.add(coordinate)
relative = f"{group.replace('.', '/')}/{artifact}/{pin}/{artifact}-{pin}.{kind}"
url = central + "/" + relative
try:
with urllib.request.urlopen(url, timeout=30) as response:
data = response.read()
except (urllib.error.URLError, OSError) as error:
if isinstance(error, urllib.error.HTTPError):
error.close()
raise GateError(f"Public input unavailable: {relative}") from error
if not data:
raise GateError(f"Empty public input: {relative}")
path = destination / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
if kind == "pom":
if coordinates(path) != (group, artifact, pin):
raise GateError(f"Public POM coordinates differ: {relative}")
root = ElementTree.fromstring(data)
ns = xml_namespace(root)
parent = root.find(f"{ns}parent")
if parent is not None:
parent_group = parent.findtext(f"{ns}groupId", "")
parent_pin = parent.findtext(f"{ns}version", "")
if not immutable_version(parent_pin):
raise GateError(f"Mutable parent in {relative}")
if parent_group.startswith("com.kingsrook"):
fetch(parent_group, parent.findtext(f"{ns}artifactId", ""), parent_pin, "pom")
if artifact == "qbit-build-parent":
imports = root.findall(f"{ns}dependencyManagement/{ns}dependencies/{ns}dependency")
props = root.find(f"{ns}properties")
values = {child.tag.removeprefix(ns): child.text for child in props} if props is not None else {}
qqq_imports = [dep for dep in imports if dep.findtext(f"{ns}groupId") == "com.kingsrook.qqq"
and dep.findtext(f"{ns}artifactId") == "qqq-bom-pom"]
if len(qqq_imports) != 1:
raise GateError("Public qbit-build-parent must import the candidate QQQ BOM")
dep = qqq_imports[0]
actual = dep.findtext(f"{ns}version", "")
actual = values.get(actual[2:-1], "") if actual.startswith("${") else actual
if (actual != version or dep.findtext(f"{ns}type") != "pom"
or dep.findtext(f"{ns}scope") != "import"):
raise GateError("Public qbit-build-parent QQQ import differs from the candidate")
receipts.append({"url": url, "sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)})
for group, artifact, pin, kind, _, classifier in validate_managed_bom(bom, version):
if group.startswith("com.kingsrook"):
if classifier or kind not in ("jar", "pom"):
raise GateError(f"Unsupported first-party release input: {artifact}")
fetch(group, artifact, pin, "pom")
if kind == "jar":
fetch(group, artifact, pin, "jar")
(destination / "receipts.json").write_text(json.dumps(receipts, indent=2) + "\n")
return receipts
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, required=True)
@@ -165,13 +268,23 @@ def main():
parser.add_argument("--smoke-jobs", type=Path, required=True)
parser.add_argument("--published-parent", type=Path, required=True)
parser.add_argument("--published-bom", type=Path, required=True)
parser.add_argument("--upstream-release", type=Path, required=True)
parser.add_argument("--next-release", type=Path, required=True)
parser.add_argument("--tag-object", type=Path, required=True)
parser.add_argument("--tag-sha", required=True)
parser.add_argument("--public-inputs", type=Path, required=True)
args = parser.parse_args()
try:
validate_poms(args.root, args.version)
validate_upstream_release(json.loads(args.upstream_release.read_text()), args.version)
validate_upstream_release(json.loads(args.next_release.read_text()),
properties(args.root / "qqq-all-bom/pom.xml")["qqq-frontend-next.version"])
validate_tag(json.loads(args.tag_object.read_text()), args.version, args.sha, args.tag_sha)
validate_smoke(json.loads(args.smoke_run.read_text()),
json.loads(args.smoke_jobs.read_text()), args.sha)
validate_published_poms(args.published_parent, args.published_bom, args.version,
args.root / "qqq-all-bom/pom.xml")
download_public_inputs(args.root / "qqq-all-bom/pom.xml", args.version, args.public_inputs)
except (GateError, OSError, ElementTree.ParseError, json.JSONDecodeError) as error:
print(f"Release gate failed: {error}", file=sys.stderr)
return 1
+76
View File
@@ -0,0 +1,76 @@
"""Exercise the delivered archive with Compose, without running any containers."""
import json
import os
import subprocess
import tarfile
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
IMAGE = "ghcr.io/qrun-io/qqq-all@sha256:" + "a" * 64
class ReleaseBundleTest(unittest.TestCase):
def test_bundle_uses_digest_and_keeps_full_profile_infra_without_app_build(self):
with tempfile.TemporaryDirectory() as directory:
result = subprocess.run(["python3", str(ROOT / "scripts/release_bundle.py"),
"--root", str(ROOT), "--version", "4.1.0-RC.1",
"--sha", "b" * 40, "--image", IMAGE, "--output", directory],
capture_output=True, text=True)
self.assertEqual(0, result.returncode, result.stderr)
archive = Path(directory) / "qqq-all-4.1.0-RC.1.tar.gz"
extracted = Path(directory) / "extracted"
with tarfile.open(archive) as bundle:
names = bundle.getnames()
self.assertIn("qqq-all-4.1.0-RC.1/infra/minio/Dockerfile", names)
self.assertIn("qqq-all-4.1.0-RC.1/infra/keycloak/realm.json", names)
self.assertIn("qqq-all-4.1.0-RC.1/.env.example", names)
self.assertNotIn("qqq-all-4.1.0-RC.1/.env", names)
self.assertNotIn("qqq-all-4.1.0-RC.1/Dockerfile", names)
bundle.extractall(extracted, filter="data")
root = extracted / "qqq-all-4.1.0-RC.1"
bundled = json.loads((root / "compose.yaml").read_text())
self.assertEqual("/home/${SFTP_USER:-qqq}/upload",
bundled["services"]["sftp"]["volumes"][0]["target"])
environment = {key: value for key, value in os.environ.items() if not key.startswith("COMPOSE_")}
result = subprocess.run(["docker", "compose", "--env-file", ".env.example", "--profile", "*",
"-f", "compose.yaml", "config", "--format", "json"],
cwd=root, env=environment, capture_output=True, text=True)
self.assertEqual(0, result.returncode, result.stderr)
config = json.loads(result.stdout)
original_result = subprocess.run(["docker", "compose", "--env-file", ".env.example", "--profile", "*",
"-f", "compose.yaml", "config", "--format", "json"],
cwd=ROOT, env=environment, capture_output=True, text=True, check=True)
original = json.loads(original_result.stdout.replace(str(ROOT.resolve()), str(root.resolve())))
for name, service in original["services"].items():
if name in ("app-core", "app-full"):
service.pop("build")
service.update(image=IMAGE, pull_policy="always")
if name == "minio":
service["image"] = "qqq-all-minio:4.1.0-RC.1"
self.assertEqual(service, config["services"][name], name)
for service in ("app-core", "app-full"):
self.assertEqual(IMAGE, config["services"][service]["image"])
self.assertNotIn("build", config["services"][service])
self.assertEqual((root / "infra/minio").resolve(), Path(config["services"]["minio"]["build"]["context"]).resolve())
for service in config["services"].values():
for volume in service.get("volumes", []):
if volume["type"] == "bind":
self.assertTrue(Path(volume["source"]).exists(), volume)
manifest = json.loads((root / "release.json").read_text())
self.assertEqual("4.1.0-RC.1", manifest["version"])
self.assertEqual("b" * 40, manifest["source_sha"])
self.assertEqual(IMAGE, manifest["image"])
def test_bundle_rejects_mutable_image_and_unapproved_version(self):
with tempfile.TemporaryDirectory() as directory:
for version, image in (("4.1.0-RC.2", IMAGE), ("4.1.0-RC.1", "ghcr.io/qrun-io/qqq-all:develop"),
("4.1.0", "ghcr.io/other/qqq-all@sha256:" + "a" * 64)):
result = subprocess.run(["python3", str(ROOT / "scripts/release_bundle.py"),
"--root", str(ROOT), "--version", version, "--sha", "b" * 40,
"--image", image, "--output", directory], capture_output=True, text=True)
self.assertNotEqual(0, result.returncode)
self.assertEqual([], list(Path(directory).iterdir()))
+8
View File
@@ -26,6 +26,10 @@ def management(qqq_version="${qqq.version}", qbit_version=None):
f"<artifactId>qbit-{name}</artifactId><version>{qbit_version(name)}</version></dependency>"
for name in QBITS
)
dependencies += (
"<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-frontend-next</artifactId>"
"<version>1.0.0-RC.8</version></dependency>"
)
return f"<dependencyManagement><dependencies>{dependencies}</dependencies></dependencyManagement>"
@@ -44,6 +48,7 @@ class ReleaseGateTest(unittest.TestCase):
(self.root / "qqq-all-bom/pom.xml").write_text(
"<project><properties>"
f"<qqq.version>{qqq_version}</qqq.version>"
"<qqq-frontend-next.version>1.0.0-RC.8</qqq-frontend-next.version>"
+ "".join(f"<qbit-{name}.version>{qbit_version}</qbit-{name}.version>" for name in QBITS)
+ "</properties>" + management() + "</project>"
)
@@ -59,6 +64,7 @@ class ReleaseGateTest(unittest.TestCase):
"<groupId>com.kingsrook.qqq</groupId>"
"<artifactId>qqq-all-parent</artifactId><version>4.1.0</version></parent>"
"<artifactId>qqq-all-bom</artifactId><properties><qqq.version>4.1.0</qqq.version>"
"<qqq-frontend-next.version>1.0.0-RC.8</qqq-frontend-next.version>"
+ "".join(f"<qbit-{name}.version>1.2.3</qbit-{name}.version>" for name in QBITS)
+ "</properties>" + managed + "</project>")
return parent, bom
@@ -97,6 +103,8 @@ class ReleaseGateTest(unittest.TestCase):
({**run, "head_sha": "b" * 40}, jobs),
({**run, "conclusion": "failure"}, jobs),
({**run, "path": ".github/workflows/other.yml"}, jobs),
({**run, "event": "workflow_dispatch"}, jobs),
({**run, "head_branch": "main"}, jobs),
(run, {"jobs": [jobs["jobs"][0]]}),
(run, {"jobs": [jobs["jobs"][0], {"name": "full", "conclusion": "skipped"}]}),
):
+151
View File
@@ -0,0 +1,151 @@
"""RC publication must retain the GA boundary and fail before any upload."""
import functools
import http.server
import tempfile
import threading
import unittest
from pathlib import Path
import release_gate
from release_gate import GateError
import test_release_gate
RC = "4.1.0-RC.1"
TAG_SHA = "b" * 40
RELEASE_SHA = "a" * 40
class QuietHandler(http.server.SimpleHTTPRequestHandler):
def log_message(self, format, *args):
pass
class ReleaseCandidateTest(unittest.TestCase):
def setUp(self):
self.fixture = test_release_gate.ReleaseGateTest()
self.fixture.setUp()
self.addCleanup(self.fixture.doCleanups)
self.root = self.fixture.root
def test_exact_rc_and_ga_are_allowed_but_other_candidates_are_not(self):
for version in ("4.1.0", RC):
self.fixture.write_poms(version, version, "1.2.3-RC.1")
release_gate.validate_poms(self.root, version)
for version in ("4.1.0-RC.2", "4.1.0-SNAPSHOT", "4.1.1", "LATEST", ""):
with self.subTest(version=version), self.assertRaises(GateError):
self.fixture.write_poms(version, version, "1.2.3")
release_gate.validate_poms(self.root, version)
def test_rc_rejects_wrong_framework_pin_and_mutable_dependencies(self):
for pin in ("4.1.0", "4.1.0-RC.2", "4.1.0-SNAPSHOT"):
self.fixture.write_poms(RC, pin, "1.2.3")
with self.assertRaises(GateError):
release_gate.validate_poms(self.root, RC)
for pin in ("LATEST", "RELEASE", "[1.0,2.0)", "1.2.3-SNAPSHOT", "${unknown}"):
self.fixture.write_poms(RC, RC, pin)
with self.subTest(pin=pin), self.assertRaises(GateError):
release_gate.validate_poms(self.root, RC)
self.fixture.write_poms(RC, RC, "1.2.3")
bom = self.root / "qqq-all-bom/pom.xml"
bom.write_text(bom.read_text().replace("1.0.0-RC.8", "LATEST"))
with self.assertRaises(GateError):
release_gate.validate_poms(self.root, RC)
def test_public_rc_distribution_poms_must_match_source_including_next(self):
self.fixture.write_poms(RC, RC, "1.2.3")
parent, bom = self.fixture.published_poms(resolved=True)
for path in (parent, bom):
path.write_text(path.read_text().replace("4.1.0", RC))
release_gate.validate_published_poms(parent, bom, RC, self.root / "qqq-all-bom/pom.xml")
original = bom.read_text()
for changed in (original.replace("1.0.0-RC.8", "1.0.0-RC.9"),
original.replace(RC, "4.1.0-RC.2"),
original.replace("1.2.3", "[1,2)")):
bom.write_text(changed)
with self.assertRaises(GateError):
release_gate.validate_published_poms(parent, bom, RC, self.root / "qqq-all-bom/pom.xml")
def test_upstream_rc_requires_exact_public_prerelease_and_ga_requires_final(self):
for version, prerelease in ((RC, True), ("4.1.0", False)):
release = {"tag_name": "v" + version, "draft": False, "prerelease": prerelease}
release_gate.validate_upstream_release(release, version)
for changed in ({**release, "draft": True}, {**release, "prerelease": not prerelease},
{**release, "tag_name": "v4.1.0-RC.2"}, {}):
with self.subTest(changed=changed), self.assertRaises(GateError):
release_gate.validate_upstream_release(changed, version)
def test_tag_requires_verified_signature_exact_name_object_and_commit(self):
tag = {"sha": TAG_SHA, "tag": RC, "object": {"type": "commit", "sha": RELEASE_SHA},
"verification": {"verified": True, "reason": "valid"}}
release_gate.validate_tag(tag, RC, RELEASE_SHA, TAG_SHA)
mutations = [
{**tag, "tag": "4.1.0"}, {**tag, "sha": "c" * 40},
{**tag, "object": {"type": "commit", "sha": "c" * 40}},
{**tag, "object": {"type": "tag", "sha": RELEASE_SHA}},
{**tag, "verification": {"verified": False, "reason": "unsigned"}},
{**tag, "verification": {"verified": True, "reason": "unknown_key"}}, {},
]
for changed in mutations:
with self.subTest(changed=changed), self.assertRaises(GateError):
release_gate.validate_tag(changed, RC, RELEASE_SHA, TAG_SHA)
def test_public_download_fails_on_missing_or_wrong_coordinates_and_parent(self):
self.fixture.write_poms(RC, RC, "1.2.3")
with tempfile.TemporaryDirectory() as directory:
central = Path(directory)
dependencies = [("com.kingsrook.qqq", "qqq-bom-pom", RC, "pom"),
("com.kingsrook.qqq", "qqq-frontend-next", "1.0.0-RC.8", "jar")]
dependencies += [("com.kingsrook.qbits", "qbit-" + name, "1.2.3", "jar")
for name in release_gate.QBITS]
for group, artifact, version, kind in dependencies:
folder = central / group.replace(".", "/") / artifact / version
folder.mkdir(parents=True)
stem = folder / (artifact + "-" + version)
Path(str(stem) + ".pom").write_text(
f"<project><groupId>{group}</groupId><artifactId>{artifact}</artifactId>"
f"<version>{version}</version></project>")
if kind == "jar":
Path(str(stem) + ".jar").write_bytes(b"published fixture jar")
handler = functools.partial(QuietHandler, directory=directory)
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
self.addCleanup(server.server_close)
self.addCleanup(server.shutdown)
base_url = f"http://127.0.0.1:{server.server_port}"
dest = self.root / "public"
bom = self.root / "qqq-all-bom/pom.xml"
receipts = release_gate.download_public_inputs(bom, RC, dest, base_url)
self.assertEqual(19, len(receipts))
self.assertTrue(all(len(row["sha256"]) == 64 for row in receipts))
public_pom = central / "com/kingsrook/qqq/qqq-frontend-next/1.0.0-RC.8/qqq-frontend-next-1.0.0-RC.8.pom"
original = public_pom.read_text()
public_pom.write_text(original.replace("1.0.0-RC.8", "1.0.0-RC.9"))
with self.assertRaises(GateError):
release_gate.download_public_inputs(bom, RC, dest, base_url)
public_pom.write_text(original.replace("</project>", "<parent><groupId>com.kingsrook</groupId>"
"<artifactId>qbit-build-parent</artifactId><version>LATEST</version></parent></project>"))
with self.assertRaises(GateError):
release_gate.download_public_inputs(bom, RC, dest, base_url)
public_pom.write_text(original.replace("</project>", "<parent><groupId>com.kingsrook</groupId>"
"<artifactId>qbit-build-parent</artifactId><version>2.1.0-RC.1</version></parent></project>"))
parent = central / "com/kingsrook/qbit-build-parent/2.1.0-RC.1/qbit-build-parent-2.1.0-RC.1.pom"
parent.parent.mkdir(parents=True)
parent.write_text("<project><groupId>com.kingsrook</groupId><artifactId>qbit-build-parent</artifactId>"
"<version>2.1.0-RC.1</version><dependencyManagement><dependencies><dependency>"
"<groupId>com.kingsrook.qqq</groupId><artifactId>qqq-bom-pom</artifactId>"
"<version>4.0.0</version><type>pom</type><scope>import</scope>"
"</dependency></dependencies></dependencyManagement></project>")
with self.assertRaises(GateError):
release_gate.download_public_inputs(bom, RC, dest, base_url)
parent.write_text(parent.read_text().replace("4.0.0", RC))
self.assertEqual(20, len(release_gate.download_public_inputs(bom, RC, dest, base_url)))
public_pom.unlink()
with self.assertRaises(GateError):
release_gate.download_public_inputs(bom, RC, dest, base_url)
if __name__ == "__main__":
unittest.main()
+51
View File
@@ -0,0 +1,51 @@
"""Run the workflow's publication shell with a recording CLI, never GitHub."""
import json
import os
import subprocess
import tempfile
import textwrap
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
def workflow_shell(name):
text = (ROOT / ".github/workflows/release.yml").read_text()
step = text.split(" - name: " + name + "\n", 1)[1].split(" - ", 1)[0]
return textwrap.dedent(step.split(" run: |\n", 1)[1])
class ReleaseWorkflowTest(unittest.TestCase):
def test_rc_is_prerelease_never_latest_and_ga_keeps_existing_release_behavior(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
gh = root / "gh"
gh.write_text("#!/usr/bin/env python3\nimport json, os, sys\n"
"with open(os.environ['CALLS'], 'w') as f: json.dump(sys.argv[1:], f)\n")
gh.chmod(0o755)
calls = root / "calls.json"
for version in ("4.1.0", "4.1.0-RC.1", "4.1.0-RC.2"):
calls.unlink(missing_ok=True)
environment = {**os.environ, "PATH": directory + os.pathsep + os.environ["PATH"],
"CALLS": str(calls), "VERSION": version, "TAG": version,
"RUNNER_TEMP": directory, "GITHUB_REPOSITORY": "QRun-IO/qqq-all"}
result = subprocess.run(["bash", "-c", workflow_shell("Publish GitHub release with Compose bundle")],
env=environment, capture_output=True, text=True)
if version == "4.1.0-RC.2":
self.assertNotEqual(0, result.returncode)
self.assertFalse(calls.exists())
continue
self.assertEqual(0, result.returncode, result.stderr)
args = json.loads(calls.read_text())
self.assertEqual(["release", "create", version], args[:3])
self.assertIn(str(root / ("qqq-all-" + version + ".tar.gz")), args)
self.assertIn("--verify-tag", args)
if version == "4.1.0-RC.1":
self.assertIn("--prerelease", args)
self.assertIn("--latest=false", args)
else:
self.assertNotIn("--prerelease", args)
self.assertNotIn("--latest=false", args)