ci: prepare gated qqq-all 4.1 publication #20
No files matched your search
@@ -0,0 +1,124 @@
|
||||
name: Release 4.1.0
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_sha:
|
||||
description: Full develop commit SHA already tagged 4.1.0
|
||||
required: true
|
||||
type: string
|
||||
smoke_run_id:
|
||||
description: Successful Smoke workflow run ID for that exact develop SHA
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: qqq-all-release-4.1.0
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
environment: qqq-all-release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_SHA: ${{ inputs.release_sha }}
|
||||
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
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Check manual release controls and exact tag
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$GITHUB_REF" = refs/heads/develop
|
||||
test "$RELEASE_ENABLED" = true
|
||||
test "$LICENSE_CLEARED" = true
|
||||
[[ "$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
|
||||
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/$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"
|
||||
for artifact in qqq-all-parent qqq-all-bom; do
|
||||
curl --fail --silent --show-error \
|
||||
"$central/$artifact/$VERSION/$artifact-$VERSION.pom" \
|
||||
--output "$RUNNER_TEMP/$artifact.pom"
|
||||
done
|
||||
python3 scripts/release_gate.py --root . --version "$VERSION" --sha "$RELEASE_SHA" \
|
||||
--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"
|
||||
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"
|
||||
if grep -Fxq "$VERSION" "$RUNNER_TEMP/ghcr-tags.txt"; then
|
||||
echo "GHCR tag $VERSION already exists" >&2
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
cache: maven
|
||||
- name: Verify release reactor
|
||||
run: mvn -B -ntp verify
|
||||
- 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)" = "$RELEASE_SHA"
|
||||
if release_check="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$TAG" 2>&1)"; then
|
||||
echo "GitHub release $TAG already exists" >&2
|
||||
exit 1
|
||||
elif [[ "$release_check" != *"HTTP 404"* ]]; then
|
||||
echo "$release_check" >&2
|
||||
exit 1
|
||||
fi
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Publish versioned image
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image="ghcr.io/qrun-io/qqq-all:$VERSION"
|
||||
docker build \
|
||||
--label org.opencontainers.image.source=https://github.com/QRun-IO/qqq-all \
|
||||
--label "org.opencontainers.image.version=$VERSION" \
|
||||
--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
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release create "$TAG" compose.yaml --repo "$GITHUB_REPOSITORY" \
|
||||
--verify-tag --title "qqq-all $VERSION" --notes-from-tag
|
||||
@@ -41,6 +41,8 @@ To stop the full stack, run `docker compose --profile full down`. Add `--volumes
|
||||
|
||||
The [develop image workflow](.github/workflows/publish-image.yml) verifies the build and publishes to GHCR only after an organization owner bootstraps the container package and sets its visibility to public. Until then, image publication is skipped and local Compose remains available.
|
||||
|
||||
The [4.1.0 release procedure](docs/release.md) describes the separate Maven parent/BOM publication path and the manually gated, exact-commit [release workflow](.github/workflows/release.yml). The workflow will not release the current snapshot build.
|
||||
|
||||
## Contributing and license
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md), the [Code of Conduct](CODE_OF_CONDUCT.md), and the [security policy](SECURITY.md). Licensed under Apache-2.0; see [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||
@@ -0,0 +1,28 @@
|
||||
# 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. Keep the workflow disabled until the owner has cleared the license/header decision and GHCR visibility.
|
||||
|
||||
## Prerequisites and Maven artifacts
|
||||
|
||||
1. QQQ must publish a non-draft, non-prerelease `v4.1.0` GitHub release with ESB and the Next dashboard included. Release `qbit-build-parent` against the 4.1 BOM, then publish all eight qbits against that parent. This work occurs in their own repositories.
|
||||
2. In a separate reviewed change to this repository, set root `revision` and the BOM's `qqq.version` to `4.1.0`; pin each qbit to a published non-snapshot version. Run `mvn -B -ntp verify`, the core and full smoke checks, and review the resolved dependencies and effective POMs. Do not use `-Drevision=4.1.0` against a snapshot source tree as a substitute for reviewed pins.
|
||||
3. Stage **both** `com.kingsrook.qqq:qqq-all-parent:4.1.0` and `com.kingsrook.qqq:qqq-all-bom:4.1.0` using the opt-in `release-central` Maven profile. The profile runs the release-version/dependency checks, signs the POMs with GPG, and uses the Central Publishing Maven plugin with `autoPublish=false`. It is only for the parent and BOM reactor; the app and BOM test JAR are excluded. POM packaging needs no source or Javadoc JARs. Configure the owner's `central` Portal user token in a private Maven `settings.xml` and a trusted GPG key outside the repository. Do not place credentials in this project or CI.
|
||||
|
||||
```bash
|
||||
mvn -B -ntp -pl qqq-all-bom -am -Prelease-central deploy
|
||||
```
|
||||
|
||||
The command uploads a deployment for **manual** Central Portal review; it does not auto-publish. Check the staged parent and BOM POM coordinates, signatures, license/SCM metadata, 4.1 QQQ import, and all eight qbit pins. Publish in the Portal only after approval. The profile fails validation while any current snapshot version remains. `mvn deploy` without the profile is not a release path.
|
||||
4. Wait until both release POMs resolve anonymously from Maven Central. The release workflow fetches them from `repo.maven.apache.org` and compares their coordinates and BOM pins with its exact source commit. If either artifact is absent or differs, it stops before image publication.
|
||||
|
||||
The owner must resolve the license/header alignment noted in the [plan](plan.md) before enabling release. The existing Apache-2.0 metadata is not itself evidence that every dependency and header is cleared. The owner must also bootstrap the `qqq-all` GHCR package and make it **Public** in package settings; first publication defaults private. The workflow checks public visibility and refuses to push a private release image.
|
||||
|
||||
## 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.
|
||||
|
||||
Configure a protected `qqq-all-release` GitHub environment with required reviewers and branch restriction to `develop`. After the owner has cleared licensing and the publication preconditions, 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.
|
||||
|
||||
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.
|
||||
@@ -50,6 +50,61 @@
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
</properties>
|
||||
|
||||
<profiles>
|
||||
<!-- Select only the parent and BOM reactor: mvn -pl qqq-all-bom -am -Prelease-central deploy. -->
|
||||
<profile>
|
||||
<id>release-central</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-enforcer-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>require-ga-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>
|
||||
</requireProperty>
|
||||
<requireReleaseDeps/>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-gpg-plugin</artifactId>
|
||||
<version>3.2.8</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>sign-release-poms</id>
|
||||
<phase>verify</phase>
|
||||
<goals><goal>sign</goal></goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.sonatype.central</groupId>
|
||||
<artifactId>central-publishing-maven-plugin</artifactId>
|
||||
<version>0.10.0</version>
|
||||
<extensions>true</extensions>
|
||||
<configuration>
|
||||
<publishingServerId>central</publishingServerId>
|
||||
<autoPublish>false</autoPublish>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
|
||||
<repositories>
|
||||
<!-- QQQ 4.1.0-SNAPSHOT and other QRun-IO snapshots are published here until 4.1 ships. -->
|
||||
<repository>
|
||||
|
||||
@@ -38,6 +38,43 @@
|
||||
<rabbitmq-jms.version>3.9.0</rabbitmq-jms.version>
|
||||
</properties>
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>release-central</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-enforcer-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>require-release-bom-pins</id>
|
||||
<phase>validate</phase>
|
||||
<goals><goal>enforce</goal></goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<requireProperty>
|
||||
<property>qqq.version</property>
|
||||
<regex>^4[.]1[.]0$</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>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<!-- QQQ modules, qqq-esb, and qqq-frontend-next. -->
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Fail-closed checks for the manual 4.1.0 release workflow."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree
|
||||
|
||||
|
||||
QBITS = (
|
||||
"quick-search", "user-role-permissions", "customizable-table-views",
|
||||
"standard-process-trace", "webhooks", "workflows", "geo-data",
|
||||
"sftp-data-integration",
|
||||
)
|
||||
|
||||
|
||||
class GateError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def properties(path):
|
||||
root = ElementTree.parse(path).getroot()
|
||||
namespace = xml_namespace(root)
|
||||
values = root.find(f"{namespace}properties")
|
||||
if values is None:
|
||||
raise GateError(f"Missing Maven properties in {path}")
|
||||
return {child.tag.removeprefix(namespace): (child.text or "").strip() for child in values}
|
||||
|
||||
|
||||
def xml_namespace(root):
|
||||
return root.tag.partition("}")[0] + "}" if root.tag.startswith("{") else ""
|
||||
|
||||
|
||||
def coordinates(path):
|
||||
root = ElementTree.parse(path).getroot()
|
||||
namespace = xml_namespace(root)
|
||||
parent = root.find(f"{namespace}parent")
|
||||
|
||||
def value(name):
|
||||
direct = root.findtext(f"{namespace}{name}")
|
||||
inherited = parent.findtext(f"{namespace}{name}") if parent is not None else None
|
||||
return direct or inherited
|
||||
|
||||
return value("groupId"), value("artifactId"), value("version")
|
||||
|
||||
|
||||
def managed_dependencies(path):
|
||||
root = ElementTree.parse(path).getroot()
|
||||
namespace = xml_namespace(root)
|
||||
values = properties(path)
|
||||
if root.findall(f"{namespace}profiles/{namespace}profile/{namespace}dependencyManagement"):
|
||||
raise GateError(f"Profile-managed dependency override in {path}")
|
||||
dependencies = root.find(f"{namespace}dependencyManagement/{namespace}dependencies")
|
||||
if dependencies is None:
|
||||
raise GateError(f"Missing dependencyManagement in {path}")
|
||||
|
||||
def field(dependency, name, default=""):
|
||||
return (dependency.findtext(f"{namespace}{name}") or default).strip()
|
||||
|
||||
managed = []
|
||||
for dependency in dependencies.findall(f"{namespace}dependency"):
|
||||
version = field(dependency, "version")
|
||||
reference = re.fullmatch(r"\$\{([^}]+)\}", version)
|
||||
if reference:
|
||||
version = values.get(reference.group(1), "")
|
||||
if not version or "${" in version or "SNAPSHOT" in version.upper():
|
||||
raise GateError(f"Unreleased managed dependency in {path}: "
|
||||
f"{field(dependency, 'artifactId')}")
|
||||
managed.append((field(dependency, "groupId"), field(dependency, "artifactId"),
|
||||
version, field(dependency, "type", "jar"),
|
||||
field(dependency, "scope", "compile"),
|
||||
field(dependency, "classifier")))
|
||||
if not managed:
|
||||
raise GateError(f"No managed dependencies in {path}")
|
||||
return managed
|
||||
|
||||
|
||||
def validate_managed_bom(path, version):
|
||||
managed = managed_dependencies(path)
|
||||
entries = {(group, artifact): (pin, kind, scope) for group, artifact, pin, kind, scope, _ in managed}
|
||||
if len(entries) != len(managed):
|
||||
raise GateError(f"Duplicate managed dependency in {path}")
|
||||
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)
|
||||
for name in QBITS:
|
||||
artifact = f"qbit-{name}"
|
||||
if entries.get(("com.kingsrook.qbits", artifact)) != (
|
||||
values.get(f"{artifact}.version"), "jar", "compile"):
|
||||
raise GateError(f"Managed {artifact} differs from its release pin in {path}")
|
||||
return managed
|
||||
|
||||
|
||||
def validate_published_poms(parent_path, bom_path, version, local_bom):
|
||||
expected = (
|
||||
(parent_path, "qqq-all-parent"),
|
||||
(bom_path, "qqq-all-bom"),
|
||||
)
|
||||
for path, artifact in expected:
|
||||
if coordinates(path) != ("com.kingsrook.qqq", artifact, version):
|
||||
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")
|
||||
for name in QBITS:
|
||||
key = f"qbit-{name}.version"
|
||||
value = published.get(key, "")
|
||||
if not value or "SNAPSHOT" in value.upper():
|
||||
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)):
|
||||
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):
|
||||
raise GateError("Published BOM dependencyManagement differs from release source")
|
||||
|
||||
|
||||
def validate_poms(root, version):
|
||||
if version != "4.1.0":
|
||||
raise GateError("This release workflow is limited to 4.1.0")
|
||||
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")
|
||||
for name in QBITS:
|
||||
key = f"qbit-{name}.version"
|
||||
value = bom.get(key, "")
|
||||
if not value or "SNAPSHOT" in value.upper():
|
||||
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")
|
||||
validate_managed_bom(root / "qqq-all-bom/pom.xml", version)
|
||||
|
||||
|
||||
def validate_smoke(run, jobs, sha):
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", sha):
|
||||
raise GateError("Release SHA must be a full lowercase Git commit ID")
|
||||
expected = {
|
||||
"name": "Smoke", "path": ".github/workflows/smoke.yml",
|
||||
"head_sha": sha, "head_branch": "develop", "event": "push",
|
||||
"conclusion": "success",
|
||||
}
|
||||
for key, value in expected.items():
|
||||
if run.get(key) != value:
|
||||
raise GateError(f"Smoke run {key} must equal {value}")
|
||||
observed = {"core": False, "full": False}
|
||||
for job in jobs.get("jobs", []):
|
||||
name = job.get("name")
|
||||
if name in observed:
|
||||
if job.get("conclusion") != "success":
|
||||
raise GateError(f"Smoke job {name} did not pass")
|
||||
observed[name] = True
|
||||
if not all(observed.values()):
|
||||
raise GateError("Smoke run must include successful core and full jobs")
|
||||
|
||||
|
||||
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("--smoke-run", type=Path, required=True)
|
||||
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)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
validate_poms(args.root, args.version)
|
||||
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")
|
||||
except (GateError, OSError, ElementTree.ParseError, json.JSONDecodeError) as error:
|
||||
print(f"Release gate failed: {error}", file=sys.stderr)
|
||||
return 1
|
||||
print("Local POM and exact-commit smoke gates passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Release gate checks use fixtures so the workflow can fail closed before publication."""
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from release_gate import GateError, validate_poms, validate_published_poms, validate_smoke
|
||||
|
||||
|
||||
RELEASE_SHA = "a" * 40
|
||||
QBITS = (
|
||||
"quick-search", "user-role-permissions", "customizable-table-views",
|
||||
"standard-process-trace", "webhooks", "workflows", "geo-data",
|
||||
"sftp-data-integration",
|
||||
)
|
||||
|
||||
|
||||
def management(qqq_version="${qqq.version}", qbit_version=None):
|
||||
qbit_version = qbit_version or (lambda name: f"${{qbit-{name}.version}}")
|
||||
dependencies = (
|
||||
"<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-bom-pom</artifactId>"
|
||||
f"<version>{qqq_version}</version><type>pom</type><scope>import</scope></dependency>"
|
||||
)
|
||||
dependencies += "".join(
|
||||
"<dependency><groupId>com.kingsrook.qbits</groupId>"
|
||||
f"<artifactId>qbit-{name}</artifactId><version>{qbit_version(name)}</version></dependency>"
|
||||
for name in QBITS
|
||||
)
|
||||
return f"<dependencyManagement><dependencies>{dependencies}</dependencies></dependencyManagement>"
|
||||
|
||||
|
||||
class ReleaseGateTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.root = Path(self.directory.name)
|
||||
(self.root / "qqq-all-bom").mkdir()
|
||||
self.write_poms("4.1.0", "4.1.0", "1.2.3")
|
||||
|
||||
def write_poms(self, revision, qqq_version, qbit_version):
|
||||
(self.root / "pom.xml").write_text(
|
||||
f"<project><properties><revision>{revision}</revision></properties></project>"
|
||||
)
|
||||
(self.root / "qqq-all-bom/pom.xml").write_text(
|
||||
"<project><properties>"
|
||||
f"<qqq.version>{qqq_version}</qqq.version>"
|
||||
+ "".join(f"<qbit-{name}.version>{qbit_version}</qbit-{name}.version>" for name in QBITS)
|
||||
+ "</properties>" + management() + "</project>"
|
||||
)
|
||||
|
||||
def published_poms(self, resolved=False):
|
||||
parent = self.root / "published-parent.pom"
|
||||
bom = self.root / "published-bom.pom"
|
||||
parent.write_text("<project xmlns='http://maven.apache.org/POM/4.0.0'>"
|
||||
"<groupId>com.kingsrook.qqq</groupId>"
|
||||
"<artifactId>qqq-all-parent</artifactId><version>4.1.0</version></project>")
|
||||
managed = (management("4.1.0", lambda name: "1.2.3") if resolved else management())
|
||||
bom.write_text("<project xmlns='http://maven.apache.org/POM/4.0.0'><parent>"
|
||||
"<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>"
|
||||
+ "".join(f"<qbit-{name}.version>1.2.3</qbit-{name}.version>" for name in QBITS)
|
||||
+ "</properties>" + managed + "</project>")
|
||||
return parent, bom
|
||||
|
||||
def test_release_poms_require_ga_versions(self):
|
||||
validate_poms(self.root, "4.1.0")
|
||||
for revision, qqq_version, qbit_version in (
|
||||
("4.1.0-SNAPSHOT", "4.1.0", "1.2.3"),
|
||||
("4.1.0", "4.1.0-SNAPSHOT", "1.2.3"),
|
||||
("4.1.0", "4.1.0", "1.2.3-SNAPSHOT"),
|
||||
):
|
||||
with self.subTest(revision=revision, qqq=qqq_version, qbit=qbit_version):
|
||||
self.write_poms(revision, qqq_version, qbit_version)
|
||||
with self.assertRaises(GateError):
|
||||
validate_poms(self.root, "4.1.0")
|
||||
self.write_poms("4.1.0", "4.1.0", "1.2.3")
|
||||
source_bom = self.root / "qqq-all-bom/pom.xml"
|
||||
source_bom.write_text(source_bom.read_text().replace(
|
||||
"<artifactId>qbit-webhooks</artifactId>",
|
||||
"<artifactId>qbit-other</artifactId>"))
|
||||
with self.assertRaises(GateError):
|
||||
validate_poms(self.root, "4.1.0")
|
||||
|
||||
def test_smoke_run_must_match_release_sha_and_both_jobs(self):
|
||||
run = {
|
||||
"name": "Smoke", "path": ".github/workflows/smoke.yml",
|
||||
"head_sha": RELEASE_SHA, "head_branch": "develop", "event": "push",
|
||||
"conclusion": "success",
|
||||
}
|
||||
jobs = {"jobs": [
|
||||
{"name": "core", "conclusion": "success"},
|
||||
{"name": "full", "conclusion": "success"},
|
||||
]}
|
||||
validate_smoke(run, jobs, RELEASE_SHA)
|
||||
for changed_run, changed_jobs in (
|
||||
({**run, "head_sha": "b" * 40}, jobs),
|
||||
({**run, "conclusion": "failure"}, jobs),
|
||||
({**run, "path": ".github/workflows/other.yml"}, jobs),
|
||||
(run, {"jobs": [jobs["jobs"][0]]}),
|
||||
(run, {"jobs": [jobs["jobs"][0], {"name": "full", "conclusion": "skipped"}]}),
|
||||
):
|
||||
with self.subTest(run=changed_run, jobs=changed_jobs):
|
||||
with self.assertRaises(GateError):
|
||||
validate_smoke(changed_run, changed_jobs, RELEASE_SHA)
|
||||
|
||||
def test_published_parent_and_bom_must_be_release_poms(self):
|
||||
parent, bom = self.published_poms()
|
||||
validate_published_poms(parent, bom, "4.1.0", self.root / "qqq-all-bom/pom.xml")
|
||||
self.published_poms(resolved=True)
|
||||
validate_published_poms(parent, bom, "4.1.0", self.root / "qqq-all-bom/pom.xml")
|
||||
parent.write_text(parent.read_text().replace("qqq-all-parent", "wrong-parent"))
|
||||
with self.assertRaises(GateError):
|
||||
validate_published_poms(parent, bom, "4.1.0", self.root / "qqq-all-bom/pom.xml")
|
||||
self.published_poms(resolved=True)
|
||||
bom.write_text(bom.read_text().replace("4.1.0", "4.1.0-SNAPSHOT"))
|
||||
with self.assertRaises(GateError):
|
||||
validate_published_poms(parent, bom, "4.1.0", self.root / "qqq-all-bom/pom.xml")
|
||||
|
||||
def test_published_bom_rejects_managed_dependency_overrides(self):
|
||||
parent, bom = self.published_poms(resolved=True)
|
||||
original = bom.read_text()
|
||||
mutations = (
|
||||
original.replace("<artifactId>qqq-bom-pom</artifactId>",
|
||||
"<artifactId>qqq-other-bom</artifactId>"),
|
||||
original.replace("<type>pom</type><scope>import</scope>",
|
||||
"<type>pom</type><scope>compile</scope>"),
|
||||
original.replace("<version>4.1.0</version><type>pom</type><scope>import</scope>",
|
||||
"<version>4.1.0-SNAPSHOT</version><type>pom</type><scope>import</scope>"),
|
||||
original.replace("<artifactId>qbit-webhooks</artifactId><version>1.2.3</version>",
|
||||
"<artifactId>qbit-webhooks</artifactId><version>1.2.3-SNAPSHOT</version>"),
|
||||
original.replace("<artifactId>qbit-webhooks</artifactId><version>1.2.3</version>",
|
||||
"<artifactId>qbit-webhooks</artifactId><version>1.2.4</version>"),
|
||||
original.replace("<dependency><groupId>com.kingsrook.qbits</groupId>"
|
||||
"<artifactId>qbit-webhooks</artifactId><version>1.2.3</version>"
|
||||
"</dependency>", ""),
|
||||
original.replace("</dependencies></dependencyManagement>",
|
||||
"<dependency><groupId>com.kingsrook.qbits</groupId>"
|
||||
"<artifactId>qbit-webhooks</artifactId><version>1.2.4</version>"
|
||||
"</dependency></dependencies></dependencyManagement>"),
|
||||
original.replace("</project>", "<profiles><profile><id>override</id>"
|
||||
"<activation><activeByDefault>true</activeByDefault></activation>"
|
||||
"<dependencyManagement><dependencies><dependency>"
|
||||
"<groupId>com.kingsrook.qbits</groupId>"
|
||||
"<artifactId>qbit-webhooks</artifactId>"
|
||||
"<version>1.2.3-SNAPSHOT</version>"
|
||||
"</dependency></dependencies></dependencyManagement>"
|
||||
"</profile></profiles></project>"),
|
||||
)
|
||||
for index, mutation in enumerate(mutations):
|
||||
with self.subTest(mutation=index):
|
||||
self.assertNotEqual(original, mutation)
|
||||
bom.write_text(mutation)
|
||||
with self.assertRaises(GateError):
|
||||
validate_published_poms(parent, bom, "4.1.0", self.root / "qqq-all-bom/pom.xml")
|
||||
bom.write_text(original.replace(
|
||||
"<qbit-webhooks.version>1.2.3</qbit-webhooks.version>",
|
||||
"<qbit-webhooks.version>1.2.4</qbit-webhooks.version>"))
|
||||
with self.assertRaises(GateError):
|
||||
validate_published_poms(parent, bom, "4.1.0", self.root / "qqq-all-bom/pom.xml")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user