From 9a92a97263fb9acdc9530389454b0f91d0be5e86 Mon Sep 17 00:00:00 2001 From: John Sell Date: Tue, 15 Sep 2026 16:58:46 -0400 Subject: [PATCH 1/4] feat: publish Konflux release bundles to the existing Quay repository --- Makefile | 6 +- pipelines/release-bundle/README.md | 109 ++++++++++++++++++ pipelines/release-bundle/pipeline.yaml | 75 ++++++++++++ scripts/release_bundle.py | 152 +++++++++++++++++++++++++ scripts/test_release_bundle.py | 94 +++++++++++++++ 5 files changed, 435 insertions(+), 1 deletion(-) create mode 100644 pipelines/release-bundle/README.md create mode 100644 pipelines/release-bundle/pipeline.yaml create mode 100644 scripts/release_bundle.py create mode 100644 scripts/test_release_bundle.py diff --git a/Makefile b/Makefile index 075da0d27..2aa19a2ac 100644 --- a/Makefile +++ b/Makefile @@ -229,7 +229,7 @@ check-dependency-age: test-dependency-age-policy PYTHONDONTWRITEBYTECODE=1 python3 scripts/check_dependency_age.py --min-age-days $(DEPENDENCY_MIN_AGE_DAYS) .PHONY: check -check: check-forbidden-terms check-dependency-pins check-ci-components check-dependency-age +check: check-forbidden-terms check-dependency-pins check-ci-components check-dependency-age test-release-bundle # ============================================================================ # Git hooks @@ -566,3 +566,7 @@ e2e-tracing: @echo " (requires: KIND_JAEGER=true make kind-up)" @echo "" @pnpm --filter @openshift-online/hypershell-web-console test:e2e:live + +.PHONY: test-release-bundle +test-release-bundle: + PYTHONDONTWRITEBYTECODE=1 python3 -m unittest scripts/test_release_bundle.py diff --git a/pipelines/release-bundle/README.md b/pipelines/release-bundle/README.md new file mode 100644 index 000000000..77cb89e38 --- /dev/null +++ b/pipelines/release-bundle/README.md @@ -0,0 +1,109 @@ +# Release bundle + +This pipeline publishes the three images from a successful Konflux managed +release as one OCI artifact. It uses the existing API server build repository: + +```text +quay.io/redhat-user-workloads/hcm-eng-prod-tenant/hypershell-main/hypershell-api-server-main +``` + +The tags start with `release-bundle-`. The artifact contains `bundle.json` with +media type `application/vnd.hypershell.release.v1+json`. Each component has its +released image reference, with a SHA-256 digest, and its source Git revision. +The component images are in `quay.io/redhat-services-prod`. + +The bundle is a release record. It is not a workload image or a Tekton task +bundle. Consumers must select the bundle tags and download the JSON layer. + +## Release checks + +The publisher requires all of these conditions: + +- The Release uses `hypershell-releaseplan` in `hcm-eng-prod-tenant`. +- The managed pipeline has status `True` and reason `Succeeded`. +- The Snapshot and release artifacts contain exactly the three expected components. +- Each released digest matches the Snapshot and is available in the release repository. +- Each source revision belongs to the history of `hypershell` main. +- Any event-type metadata identifies a push event. + +The final pipeline can run after a failed managed pipeline. It must check +`ManagedPipelineProcessed`; `Released` is not complete until the final pipeline +finishes. A failed check stops publication. + +Konflux can keep an earlier image for an unchanged component. The bundle keeps +all three source revisions. A Snapshot can also contain an earlier image while +another component build is still running. This pipeline preserves the accepted +Snapshot; it does not add a test that waits for all builds from one commit. + +The tag uses the Snapshot creation time and a hash of the Release UID. The OCI +creation time also uses the Snapshot time. A retry of an old release does not +receive a new creation time. Retries of the same Release produce the same content +and digest. Consumers must retain and use the bundle digest. + +## Enable the pipeline through a merge request + +Merge the source PR first. In `releng/konflux-release-data`, edit: + +```text +tenants-config/cluster/stone-prd-rh01/tenants/hcm-eng-prod-tenant/hypershell/appstudio.redhat.com.releaseplan.yaml +``` + +Set `spec.finalPipeline` to this pipeline through the Git resolver. Use +`https://github.com/openshift-online/hypershell.git`, the merged source commit +SHA, and `pipelines/release-bundle/pipeline.yaml`. Set `useEmptyDir: true` and +`serviceAccountName: build-pipeline-hypershell-api-server-main`. + +Give this service account `get` access to `releases` and `snapshots` in API group +`appstudio.redhat.com` in the tenant namespace. It does not need list, watch, +update, or Git write access. The existing build account supplies the Quay write +credential through Tekton credential initialization. Do not put a token in Git. + +The first run verifies the actual namespace permissions and registry credential. +If either is missing, the run fails and publishes no bundle. The source PR alone +does not enable the pipeline. + +## Kargo consumer + +Use one image subscription for the bundle repository. For example: + +```yaml +spec: + subscriptions: + - image: + repoURL: quay.io/redhat-user-workloads/hcm-eng-prod-tenant/hypershell-main/hypershell-api-server-main + imageSelectionStrategy: Lexical + allowTagsRegexes: + - '^release-bundle-[0-9]{8}T[0-9]{12}Z-[0-9a-f]{16}$' +``` + +A promotion must download the selected Freight digest with `oci-download` and +select media type `application/vnd.hypershell.release.v1+json`. It can then read +the component references from the JSON and commit the three image digest changes +to `hypershell-gitops`. Argo CD in the destination cluster pulls that commit. + +Kargo selects the latest eligible tag at each poll. It does not guarantee a +separate deployment for every intermediate release. A later deployment step must +also prevent an older Snapshot from replacing a newer deployed image set. + +## First cluster test + +1. Merge the source PR and then the tenant config MR. +2. Let a main component build complete and pass the configured release checks. +3. Check the final PipelineRun. Its `bundle` result must contain an OCI digest. +4. Download that digest and check the three component digests and source revisions. +5. Configure Kargo to discover that artifact before enabling automatic promotion. + +No cluster login is required to submit these changes. The first Konflux run is +still required to prove the live credentials. The local checks cannot prove them. + +## Local checks + +Run `make test-release-bundle` and `make check`. + +References: + +- [Konflux tenant and final pipelines](https://konflux-ci.dev/docs/releasing/tenant-release-pipelines/) +- [Konflux Snapshots](https://konflux-ci.dev/docs/testing/integration/snapshots/) +- [Tekton credentials](https://tekton.dev/docs/pipelines/auth/) +- [Kargo Warehouses](https://docs.kargo.io/user-guide/how-to-guides/working-with-warehouses) +- [Kargo OCI download](https://docs.kargo.io/user-guide/reference-docs/promotion-steps/oci-download) diff --git a/pipelines/release-bundle/pipeline.yaml b/pipelines/release-bundle/pipeline.yaml new file mode 100644 index 000000000..1e90df4ac --- /dev/null +++ b/pipelines/release-bundle/pipeline.yaml @@ -0,0 +1,75 @@ +apiVersion: tekton.dev/v1 +kind: Pipeline +metadata: + name: hypershell-release-bundle +spec: + description: Publish a bundle after the managed release succeeds. + params: + - name: release + type: string + - name: releasePlan + type: string + - name: snapshot + type: string + - name: taskGitUrl + type: string + - name: taskGitRevision + type: string + workspaces: + - name: release-workspace + results: + - name: bundle + description: The bundle image reference with its digest. + value: $(tasks.publish.results.bundle) + tasks: + - name: publish + params: + - name: release + value: $(params.release) + - name: snapshot + value: $(params.snapshot) + - name: revision + value: $(params.taskGitRevision) + taskSpec: + params: + - name: release + type: string + - name: snapshot + type: string + - name: revision + type: string + results: + - name: bundle + type: string + steps: + - name: publish + image: quay.io/konflux-ci/release-service-utils@sha256:3cb03b14ac9d90ff27070036ce2b50712e65aa285daeb28852254a745bb25dfc + computeResources: + requests: + cpu: 100m + memory: 256Mi + limits: + memory: 1Gi + env: + - name: RELEASE + value: $(params.release) + - name: SNAPSHOT + value: $(params.snapshot) + - name: PIPELINE_REVISION + value: $(params.revision) + - name: BUNDLE_RESULT + value: $(results.bundle.path) + script: | + #!/usr/bin/env bash + set -euo pipefail + [[ "$PIPELINE_REVISION" =~ ^[0-9a-f]{40}$ ]] + source_dir=$(mktemp -d) + trap 'rm -rf "$source_dir"' EXIT + git -C "$source_dir" init --quiet + git -C "$source_dir" remote add origin https://github.com/openshift-online/hypershell.git + git -C "$source_dir" fetch --quiet --filter=blob:none origin \ + main:refs/remotes/origin/main "$PIPELINE_REVISION" + git -C "$source_dir" checkout --quiet "$PIPELINE_REVISION" -- scripts/release_bundle.py + python3 "$source_dir/scripts/release_bundle.py" \ + --release "$RELEASE" --snapshot "$SNAPSHOT" \ + --source-directory "$source_dir" --result-path "$BUNDLE_RESULT" diff --git a/scripts/release_bundle.py b/scripts/release_bundle.py new file mode 100644 index 000000000..2a3f94d5e --- /dev/null +++ b/scripts/release_bundle.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Publish the image set from a successful Konflux managed release.""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +import re +import subprocess +import tempfile + +APPLICATION = "hypershell-main" +NAMESPACE = "hcm-eng-prod-tenant" +SOURCE_URL = "https://github.com/openshift-online/hypershell" +BUILD_PREFIX = f"quay.io/redhat-user-workloads/{NAMESPACE}/{APPLICATION}/" +RELEASE_PREFIX = f"quay.io/redhat-services-prod/{NAMESPACE}/{APPLICATION}/" +COMPONENTS = ( + "hypershell-api-server-main", + "hypershell-control-plane-main", + "hypershell-web-console-main", +) +BUNDLE_REPOSITORY = BUILD_PREFIX + COMPONENTS[0] +MEDIA_TYPE = "application/vnd.hypershell.release.v1+json" + + +def require(value, message): + if not value: + raise ValueError(message) + + +def indexed(items): + result = {item["name"]: item for item in items} + require(len(result) == len(items), "Duplicate component names") + require(set(result) == set(COMPONENTS), "The release must contain all three components") + return result + + +def make_bundle(release, snapshot): + """Reject incomplete or unsuccessful releases before registry operations.""" + metadata = release["metadata"] + require(metadata["namespace"] == NAMESPACE, "Unexpected release namespace") + require(snapshot["metadata"]["namespace"] == NAMESPACE, "Unexpected snapshot namespace") + require(release["spec"]["releasePlan"] == "hypershell-releaseplan", "Unexpected release plan") + require(release["spec"]["snapshot"] == snapshot["metadata"]["name"], "Snapshot mismatch") + require(snapshot["spec"]["application"] == APPLICATION, "Unexpected application") + conditions = {c["type"]: c for c in release.get("status", {}).get("conditions", [])} + managed = conditions.get("ManagedPipelineProcessed", {}) + require(managed.get("status") == "True" and managed.get("reason") == "Succeeded", + "The managed release did not succeed") + for obj in (release, snapshot): + for field in ("annotations", "labels"): + for key, value in obj["metadata"].get(field, {}).items(): + if key.endswith("/event-type"): + require(value == "push", "Only push snapshots can produce bundles") + require(not key.endswith("/pull-request"), "Pull request snapshots cannot produce bundles") + components = indexed(snapshot["spec"]["components"]) + images = indexed(release["status"].get("artifacts", {}).get("images", [])) + output = [] + for name in COMPONENTS: + component, image = components[name], images[name] + digest = image["shasum"] + require(re.fullmatch(r"sha256:[0-9a-f]{64}", digest), "Invalid image digest") + require(component["containerImage"] == BUILD_PREFIX + name + "@" + digest, + "Released digest does not match the snapshot") + repository = RELEASE_PREFIX + name + require(any(url.startswith(repository + ":") for url in image["urls"]), + "The release has no expected destination repository") + source = component["source"]["git"] + require(source["url"].removesuffix(".git") == SOURCE_URL, "Unexpected source repository") + require(re.fullmatch(r"[0-9a-f]{40}", source["revision"]), "Invalid source revision") + output.append({"name": name, "image": repository + "@" + digest, + "source": {"git": {"url": SOURCE_URL, "revision": source["revision"]}}}) + created = snapshot["metadata"]["creationTimestamp"] + stamp = datetime.fromisoformat(created.replace("Z", "+00:00")) + require(stamp.utcoffset() is not None, "Snapshot time must include a timezone") + stamp = stamp.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + identity = hashlib.sha256(metadata["uid"].encode()).hexdigest()[:16] + tag = f"release-bundle-{stamp}-{identity}" + return tag, { + "schemaVersion": 1, + "application": APPLICATION, + "created": created, + "release": {"namespace": NAMESPACE, "name": metadata["name"], "uid": metadata["uid"]}, + "snapshot": {"name": snapshot["metadata"]["name"], "uid": snapshot["metadata"]["uid"]}, + "components": output, + } + + +def run(*args, **kwargs): + return subprocess.check_output(args, text=True, **kwargs).strip() + + +def resource(kind, reference): + namespace, name = reference.split("/") + require(namespace == NAMESPACE, "Unexpected resource namespace") + require(re.fullmatch(r"[a-z0-9][a-z0-9.-]*", name), "Invalid resource name") + return json.loads(run("kubectl", "get", kind, name, "-n", namespace, "-o", "json")) + + +def publish(release, snapshot, source_directory, result_path): + tag, bundle = make_bundle(release, snapshot) + for component in bundle["components"]: + # The pipeline fetches main from the fixed public source repository. + run("git", "merge-base", "--is-ancestor", component["source"]["git"]["revision"], + "refs/remotes/origin/main", cwd=source_directory) + require(run("oras", "resolve", component["image"]) == component["image"].split("@")[1], + "Released image is not available by digest") + # Konflux credentials can be scoped to a repository. ORAS needs a host entry. + credentials = json.loads(run("select-oci-auth", BUNDLE_REPOSITORY)) + require(credentials.get("auths", {}).get("quay.io"), + "The build service account has no registry credentials") + target = BUNDLE_REPOSITORY + ":" + tag + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + auth = root / "auth.json" + auth.touch(mode=0o600) + auth.write_text(json.dumps(credentials)) + (root / "bundle.json").write_text(json.dumps(bundle, sort_keys=True) + "\n") + (root / "config.json").write_text(json.dumps({ + "created": bundle["created"], "architecture": "amd64", "os": "linux", + "config": {}, "rootfs": {"type": "layers", "diff_ids": []}, + }, sort_keys=True) + "\n") + # A fixed creation time makes retries of the same Release reproducible. + run("oras", "push", "--registry-config", str(auth), "--image-spec", "v1.0", + "--annotation", "org.opencontainers.image.created=" + bundle["created"], + "--config", "config.json:application/vnd.oci.image.config.v1+json", + "--export-manifest", "manifest.json", target, "bundle.json:" + MEDIA_TYPE, + cwd=root) + digest = "sha256:" + hashlib.sha256((root / "manifest.json").read_bytes()).hexdigest() + require(run("oras", "resolve", "--registry-config", str(auth), target) == digest, + "Published bundle digest mismatch") + Path(result_path).write_text(BUNDLE_REPOSITORY + "@" + digest) + print("Published " + target + "@" + digest) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--release", required=True) + parser.add_argument("--snapshot", required=True) + parser.add_argument("--source-directory", required=True) + parser.add_argument("--result-path", required=True) + args = parser.parse_args() + publish(resource("releases.appstudio.redhat.com", args.release), + resource("snapshots.appstudio.redhat.com", args.snapshot), + args.source_directory, args.result_path) + + +if __name__ == "__main__": + main() diff --git a/scripts/test_release_bundle.py b/scripts/test_release_bundle.py new file mode 100644 index 000000000..d4fe9b80e --- /dev/null +++ b/scripts/test_release_bundle.py @@ -0,0 +1,94 @@ +"""Check release gates and the bundle contents without cluster access.""" + +import copy +import unittest +from unittest.mock import patch + +from scripts import release_bundle as bundle + + +def fixtures(): + components, images = [], [] + for index, name in enumerate(bundle.COMPONENTS): + digest = "sha256:" + str(index + 1) * 64 + components.append({"name": name, "containerImage": bundle.BUILD_PREFIX + name + "@" + digest, + "source": {"git": {"url": bundle.SOURCE_URL, "revision": str(index + 1) * 40}}}) + images.append({"name": name, "shasum": digest, + "urls": [bundle.RELEASE_PREFIX + name + ":latest"]}) + snapshot = {"metadata": {"name": "snapshot-one", "namespace": bundle.NAMESPACE, + "uid": "snapshot-uid", "creationTimestamp": "2026-09-15T12:00:00Z"}, + "spec": {"application": bundle.APPLICATION, "components": components}} + release = {"metadata": {"name": "release-one", "namespace": bundle.NAMESPACE, "uid": "release-uid"}, + "spec": {"snapshot": "snapshot-one", "releasePlan": "hypershell-releaseplan"}, + "status": {"conditions": [{"type": "ManagedPipelineProcessed", "status": "True", + "reason": "Succeeded"}], "artifacts": {"images": images}}} + return release, snapshot + + +class ReleaseBundleTests(unittest.TestCase): + def test_preserves_each_component_revision_and_released_digest(self): + release, snapshot = fixtures() + tag, result = bundle.make_bundle(release, snapshot) + self.assertTrue(tag.startswith("release-bundle-20260915T120000000000Z-")) + for item, source in zip(result["components"], snapshot["spec"]["components"]): + self.assertEqual(item["source"], source["source"]) + self.assertEqual(item["image"], source["containerImage"].replace(bundle.BUILD_PREFIX, bundle.RELEASE_PREFIX)) + self.assertEqual((tag, result), bundle.make_bundle(release, snapshot)) + + def test_failed_pending_and_skipped_releases_cannot_reach_registry(self): + for status, reason in (("False", "Failed"), ("Unknown", "Progressing"), ("True", "Skipped")): + release, snapshot = fixtures() + release["status"]["conditions"][0].update(status=status, reason=reason) + with self.subTest(status=status, reason=reason), patch.object(bundle, "run") as run: + with self.assertRaises(ValueError): + bundle.publish(release, snapshot, ".", "/unused") + run.assert_not_called() + + def test_missing_or_duplicate_components_fail(self): + for location in ("snapshot", "release"): + for duplicate in (False, True): + release, snapshot = fixtures() + items = snapshot["spec"]["components"] if location == "snapshot" else release["status"]["artifacts"]["images"] + if duplicate: + items.append(copy.deepcopy(items[0])) + else: + items.pop() + with self.subTest(location=location, duplicate=duplicate), self.assertRaises(ValueError): + bundle.make_bundle(release, snapshot) + + def test_wrong_image_or_source_fails(self): + mutations = ( + lambda r, s: r["status"]["artifacts"]["images"][0].update(shasum="sha256:" + "a" * 64), + lambda r, s: r["status"]["artifacts"]["images"][0].update(urls=["quay.io/other/repo:latest"]), + lambda r, s: s["spec"]["components"][0]["source"]["git"].update(revision="main"), + lambda r, s: s["spec"]["components"][0]["source"]["git"].update(url="https://example.com/repo"), + lambda r, s: r["spec"].update(snapshot="other"), + lambda r, s: s["spec"].update(application="other"), + ) + for mutate in mutations: + release, snapshot = fixtures() + mutate(release, snapshot) + with self.assertRaises(ValueError): + bundle.make_bundle(release, snapshot) + + def test_pull_request_and_merge_queue_events_fail(self): + for field in ("annotations", "labels"): + for event in ("pull_request", "merge_group"): + release, snapshot = fixtures() + snapshot["metadata"][field] = {"pac.test.appstudio.openshift.io/event-type": event} + with self.subTest(field=field, event=event), self.assertRaises(ValueError): + bundle.make_bundle(release, snapshot) + + def test_old_snapshot_does_not_get_a_new_timestamp_on_retry(self): + release, snapshot = fixtures() + first, _ = bundle.make_bundle(release, snapshot) + release["metadata"]["uid"] = "retry-uid" + retry, _ = bundle.make_bundle(release, snapshot) + self.assertEqual(first.split("-")[2], retry.split("-")[2]) + snapshot["metadata"]["creationTimestamp"] = "2026-09-16T12:00:00Z" + newer, _ = bundle.make_bundle(release, snapshot) + self.assertGreater(newer, retry) + + +if __name__ == "__main__": + unittest.main() From ae1f469fbffbd760a39387709d30de52f5dd430c Mon Sep 17 00:00:00 2001 From: John Sell Date: Tue, 15 Sep 2026 17:01:40 -0400 Subject: [PATCH 2/4] docs: use the approved Konflux viewer role --- pipelines/release-bundle/README.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/pipelines/release-bundle/README.md b/pipelines/release-bundle/README.md index 77cb89e38..61cf5635f 100644 --- a/pipelines/release-bundle/README.md +++ b/pipelines/release-bundle/README.md @@ -53,10 +53,14 @@ Set `spec.finalPipeline` to this pipeline through the Git resolver. Use SHA, and `pipelines/release-bundle/pipeline.yaml`. Set `useEmptyDir: true` and `serviceAccountName: build-pipeline-hypershell-api-server-main`. -Give this service account `get` access to `releases` and `snapshots` in API group -`appstudio.redhat.com` in the tenant namespace. It does not need list, watch, -update, or Git write access. The existing build account supplies the Quay write -credential through Tekton credential initialization. Do not put a token in Git. +Bind this service account to the approved `konflux-viewer-bot-actions` ClusterRole +in the tenant namespace. The config repository rejects custom roles. The viewer +role permits reads of Releases, Snapshots, and other Konflux resources. It does +not grant writes. The publisher uses only `get` on Releases and Snapshots. + +The existing build account already has Quay write access. Reuse its credential +through Tekton credential initialization and the `select-oci-auth` helper. No new +Quay token or repository is required. Do not put a token in Git. The first run verifies the actual namespace permissions and registry credential. If either is missing, the run fails and publishes no bundle. The source PR alone From 05a8b4cb68d17ce6935165c73e36addefb295d31 Mon Sep 17 00:00:00 2001 From: John Sell Date: Tue, 15 Sep 2026 17:23:51 -0400 Subject: [PATCH 3/4] fix(release): restrict bundle publisher container permissions --- pipelines/release-bundle/pipeline.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pipelines/release-bundle/pipeline.yaml b/pipelines/release-bundle/pipeline.yaml index 1e90df4ac..251710b56 100644 --- a/pipelines/release-bundle/pipeline.yaml +++ b/pipelines/release-bundle/pipeline.yaml @@ -44,6 +44,14 @@ spec: steps: - name: publish image: quay.io/konflux-ci/release-service-utils@sha256:3cb03b14ac9d90ff27070036ce2b50712e65aa285daeb28852254a745bb25dfc + securityContext: + runAsNonRoot: true + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault computeResources: requests: cpu: 100m From afdbe4a8b7813ef9819931e8bcd555669b1a1bc4 Mon Sep 17 00:00:00 2001 From: John Sell Date: Tue, 15 Sep 2026 17:30:03 -0400 Subject: [PATCH 4/4] fix(release): use the resolved pipeline commit for bundle publication --- pipelines/release-bundle/README.md | 12 ++++-- pipelines/release-bundle/pipeline.yaml | 29 +++++++++---- scripts/test_release_bundle.py | 56 ++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 11 deletions(-) diff --git a/pipelines/release-bundle/README.md b/pipelines/release-bundle/README.md index 61cf5635f..97c9cb0d7 100644 --- a/pipelines/release-bundle/README.md +++ b/pipelines/release-bundle/README.md @@ -49,14 +49,20 @@ tenants-config/cluster/stone-prd-rh01/tenants/hcm-eng-prod-tenant/hypershell/app ``` Set `spec.finalPipeline` to this pipeline through the Git resolver. Use -`https://github.com/openshift-online/hypershell.git`, the merged source commit -SHA, and `pipelines/release-bundle/pipeline.yaml`. Set `useEmptyDir: true` and +`https://github.com/openshift-online/hypershell.git`, revision `main`, and +`pipelines/release-bundle/pipeline.yaml`. Set `useEmptyDir: true` and `serviceAccountName: build-pipeline-hypershell-api-server-main`. +Each new run resolves the pipeline from `main`. The publisher reads the resolved +commit from `status.provenance.refSource.digest.sha1` on its PipelineRun and +fetches the Python script from that commit. A change to `main` during the run +cannot change the script version. Missing provenance stops publication. + Bind this service account to the approved `konflux-viewer-bot-actions` ClusterRole in the tenant namespace. The config repository rejects custom roles. The viewer role permits reads of Releases, Snapshots, and other Konflux resources. It does -not grant writes. The publisher uses only `get` on Releases and Snapshots. +not grant writes. The publisher uses only `get` on Releases, Snapshots, and its +PipelineRun. Verify these reads in the first cluster run. The existing build account already has Quay write access. Reuse its credential through Tekton credential initialization and the `select-oci-auth` helper. No new diff --git a/pipelines/release-bundle/pipeline.yaml b/pipelines/release-bundle/pipeline.yaml index 251710b56..8dc164a33 100644 --- a/pipelines/release-bundle/pipeline.yaml +++ b/pipelines/release-bundle/pipeline.yaml @@ -28,15 +28,19 @@ spec: value: $(params.release) - name: snapshot value: $(params.snapshot) - - name: revision - value: $(params.taskGitRevision) + - name: pipeline-run + value: $(context.pipelineRun.name) + - name: pipeline-namespace + value: $(context.pipelineRun.namespace) taskSpec: params: - name: release type: string - name: snapshot type: string - - name: revision + - name: pipeline-run + type: string + - name: pipeline-namespace type: string results: - name: bundle @@ -63,21 +67,30 @@ spec: value: $(params.release) - name: SNAPSHOT value: $(params.snapshot) - - name: PIPELINE_REVISION - value: $(params.revision) + - name: PIPELINE_RUN + value: $(params.pipeline-run) + - name: PIPELINE_NAMESPACE + value: $(params.pipeline-namespace) - name: BUNDLE_RESULT value: $(results.bundle.path) script: | #!/usr/bin/env bash set -euo pipefail - [[ "$PIPELINE_REVISION" =~ ^[0-9a-f]{40}$ ]] + # Use the commit that Tekton resolved, even if main has since changed. + pipeline_revision=$(kubectl get pipelineruns.tekton.dev "$PIPELINE_RUN" \ + -n "$PIPELINE_NAMESPACE" \ + -o jsonpath='{.status.provenance.refSource.digest.sha1}') + if [[ ! "$pipeline_revision" =~ ^[0-9a-f]{40}$ ]]; then + echo 'The PipelineRun has no valid resolved Git commit.' >&2 + exit 1 + fi source_dir=$(mktemp -d) trap 'rm -rf "$source_dir"' EXIT git -C "$source_dir" init --quiet git -C "$source_dir" remote add origin https://github.com/openshift-online/hypershell.git git -C "$source_dir" fetch --quiet --filter=blob:none origin \ - main:refs/remotes/origin/main "$PIPELINE_REVISION" - git -C "$source_dir" checkout --quiet "$PIPELINE_REVISION" -- scripts/release_bundle.py + main:refs/remotes/origin/main "$pipeline_revision" + git -C "$source_dir" checkout --quiet "$pipeline_revision" -- scripts/release_bundle.py python3 "$source_dir/scripts/release_bundle.py" \ --release "$RELEASE" --snapshot "$SNAPSHOT" \ --source-directory "$source_dir" --result-path "$BUNDLE_RESULT" diff --git a/scripts/test_release_bundle.py b/scripts/test_release_bundle.py index d4fe9b80e..73ad4068f 100644 --- a/scripts/test_release_bundle.py +++ b/scripts/test_release_bundle.py @@ -1,6 +1,11 @@ """Check release gates and the bundle contents without cluster access.""" import copy +import os +from pathlib import Path +import subprocess +import tempfile +import textwrap import unittest from unittest.mock import patch @@ -90,5 +95,56 @@ def test_old_snapshot_does_not_get_a_new_timestamp_on_retry(self): self.assertGreater(newer, retry) +class PipelineBootstrapTests(unittest.TestCase): + def run_bootstrap(self, revision, read_status=0): + pipeline = Path(__file__).resolve().parents[1] / "pipelines/release-bundle/pipeline.yaml" + script = textwrap.dedent(pipeline.read_text().split(" script: |\n", 1)[1]) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + commands = root / "commands" + stubs = { + "kubectl": 'printf "%s\\n" "$*" >> "$COMMAND_LOG"\n' + 'printf "%s" "$RESOLVED_REVISION"\nexit "$READ_STATUS"\n', + "git": 'printf "git %s\\n" "$*" >> "$COMMAND_LOG"\n', + "python3": 'printf "publisher started\\n" >> "$COMMAND_LOG"\n', + } + for name, body in stubs.items(): + executable = root / name + executable.write_text("#!/bin/sh\n" + body) + executable.chmod(0o755) + env = dict(os.environ, PATH=str(root) + os.pathsep + os.environ["PATH"], + COMMAND_LOG=str(commands), RESOLVED_REVISION=revision, + READ_STATUS=str(read_status), PIPELINE_RUN="final-one", + PIPELINE_NAMESPACE=bundle.NAMESPACE, RELEASE="release-one", + SNAPSHOT="snapshot-one", BUNDLE_RESULT=str(root / "result")) + result = subprocess.run(["bash", "-c", script], env=env, text=True, capture_output=True) + return result, commands.read_text() + + def test_script_uses_pipeline_commit_instead_of_main(self): + revision = "a" * 40 + result, commands = self.run_bootstrap(revision) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("get pipelineruns.tekton.dev final-one -n " + bundle.NAMESPACE, commands) + self.assertIn("{.status.provenance.refSource.digest.sha1}", commands) + self.assertIn("main:refs/remotes/origin/main " + revision, commands) + self.assertIn("checkout --quiet " + revision + " -- scripts/release_bundle.py", commands) + self.assertIn("publisher started", commands) + + def test_missing_or_invalid_commit_stops_before_fetch(self): + for revision in ("", "main", "a" * 39, "$(touch /tmp/unexpected)"): + with self.subTest(revision=revision): + result, commands = self.run_bootstrap(revision) + self.assertNotEqual(result.returncode, 0) + self.assertIn("no valid resolved Git commit", result.stderr) + self.assertNotIn("git ", commands) + self.assertNotIn("publisher started", commands) + + def test_pipeline_read_failure_stops_publication(self): + result, commands = self.run_bootstrap("a" * 40, read_status=1) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("git ", commands) + self.assertNotIn("publisher started", commands) + + if __name__ == "__main__": unittest.main()