Docs · Glidepath
Release stage: GitOps PR promotion to staging
The release stage promotes the exact image that already passed test/deploy-to-dev
(never rebuilt) to a staging environment, via a pull request against a dedicated
GitOps repo - gated by governance checks reported as independent GitHub Checks and a
required-reviewer branch-protection rule. See ADR-0004
for why release is built this way rather than a direct-deploy path.
Flow
deploy (dev) succeeds
-> dev.cdevents.service.deployed CDEvent, now carrying git-url/revision forward
-> broker fires `release` PipelineRun
-> release opens a PR against gitops-<app-name>, bumping the image tag in
<cluster>/<env>/values.yaml (charts/glidepath-catalog/templates/tasks/
open-release-pr.yaml) - this is where the automated part of the flow ends; the
flow-root trace closes here (see docs/tracing.md)
-> the release-guardrail GitHub Checks run on that PR, one per gate in
.Values.releaseGuardrails (sast/image-scan/provenance/sbom/values/qa real, itsm real
once a change tool is configured, policy-validation real since 2026-10-26 - see
[release-guardrails.md](release-guardrails.md)/[governance-stubs.md](governance-stubs.md)),
each individually re-triggerable and independent of the others (the
`image-promotion` check that waited on the rest was retired 2026-10-09, ADR-0025; with
catalog `promotion.enabled` a real `image-promotion` check follows the last required gate,
ADR-0029, [registries.md](registries.md))
-> branch protection on the gitops repo requires every one of those checks + N human
reviewers
Once ArgoCD confirms this release's own sync outcome, `release-outcome-notify.yaml`
also emits a structured release-log record (approvers, per-gate results, bypass) -
see [release-log.md](release-log.md).
`provenance` verifies the actual APP-repo commit that triggered this release (see
docs/admin/commit-signing.md) - not this gitops PR's own commits, which are
machine-generated and never signed. The promoted commit passes if it is
gitsign-signed by an allowed signer, or - only for an app whose admin-owned
release-signing policy turns the GitHub-merge path on (off by default) - if GitHub
signed it as the merge of a PR into the default branch that an allowed login merged.
Otherwise a GitHub merge-button click fails the gate (merge/squash/rebase all make a
new commit nobody gitsign-signed) - see commit-signing.md's "Merge strategy" section.
-> once merged, ArgoCD (syncPolicy.automated: prune+selfHeal) syncs the new manifest
into <type>-<app-name>-staging - the release Pipeline never touches the cluster directly
Performance: no clone, no schema re-validation, for the common (event-chained) case
Neither deploy.yaml (deploy-manifests.yaml does its own separate clone of the app
repo itself, via the GitHub App token broker, to commit glidepath/envs/<env>.yaml) nor
release.yaml (open-release-pr.yaml does its own separate clone of the gitops repo,
a different repo entirely) ever reads from the app repo’s source tree through this
Pipeline’s own source workspace. Both used to
unconditionally clone it and run full JSON-schema validation on cicd.yaml anyway,
purely so notify-slack’s finally step could read two fields off it - a full clone plus
a dynamically-provisioned PVC, on every single stage transition, for a workspace that
was otherwise dead weight.
2026-10-09: the mechanism below is now the
clone/validatesteps of every stage’s singlepreflightTask (stage-preflight.yaml);resolve-notify-configand the standalonevalidate-config/clone-repotasks are gone. A git-rooted deploy/release now gets full schema validation and defaults, not a best-effort read.
Fixed: cicd.yaml’s already-validated content is now forwarded stage-to-stage through
the CDEvents chain instead (customData.platform.config_json - see docs/chaining.md),
ultimately sourced from test’s own validate-config (which always runs regardless,
needed for resolve-agent-image/integration-test). deploy.yaml/release.yaml’s
source workspace is optional: true, and flow-triggers.yaml skips binding it
entirely for the common event-chained case, which is what actually avoids the PVC
provisioning cost, not just a clone. catalog/tasks/resolve-notify-config.yaml is the
arbiter: pass through the inherited value, or (only for a git-rooted deploy/release,
where a real workspace is bound) clone and read cicd.yaml directly off it - there’s no
separate clone-repo task any more. That last point isn’t a style choice: Tekton
rejects a Pipeline at admission time if any task references an optional Pipeline
workspace through its own required Task-level workspace, regardless of when-gating -
confirmed live the hard way (the shared, hub-resolved git-clone catalog Task declares
its workspace required, so it can never coexist with an optional Pipeline workspace, no
matter how it’s gated). See resolve-notify-config.yaml’s own header for the full story.
Tradeoff, deliberate: that git-rooted fallback read is not schema-validated the way
validate-config does it - deploy.yaml/release.yaml dropped validate-config
entirely, since nothing else in either pipeline ever consumed its output. This relies on
cicd.yaml having already been schema-validated earlier in the app’s lifecycle
(onboarding-resync validates it when generating the .tekton/ files in the first
place, and build validates it again for any flow that includes a build step) - a
malformed cicd.yaml reaching a git-rooted deploy/release with no earlier validation
in its own history is a real, if narrow, gap. Revisit if that ever proves to matter in
practice (every real git-rooted deploy/release use case today is tag-triggered - see
resolve-image-ref.yaml’s own header).
How the GitHub App’s private key stays out of Application namespaces
The GitHub App used for git operations (clone/push/PR-open) is the same App PaC already
uses - but its private key lives in a Secret in the pipelines-as-code namespace, which
an Application pipeline pod (running in its own namespace) can’t mount directly
(Kubernetes Secrets are namespace-scoped). Rather than copy the key into every
Application’s namespace (which would let one compromised Application’s Task mint tokens
for every other Application’s repos), the shared token-review-interceptor service -
already deployed, already TokenReview-authenticated for the CDEvents broker path - has a
second endpoint, /github-installation-token, that:
- Verifies the caller’s own Application identity via TokenReview (same mechanism as the broker path).
- Checks the caller’s Application namespace actually owns a PaC
RepositoryCR that maps to the requestedgitops-<app-name>repo by name - so an Application can only ever get a token for its own release repo, never another Application’s. - Mints a GitHub App installation token scoped to only that one repo (via GitHub’s
repositoriesfield on the installation-token exchange) and returns it. The private key itself never leavesplatform-system.
catalog/lib/github-app.sh wraps this call for open-release-pr.yaml.
The GitHub read token
glidepath-api’s promote and rollback (api.md) read GitHub the way Tower’s do: the
app’s cicd.yaml, a source environment’s release file, a commit, and the open release PR. The API holds no GitHub
credential, so the same broker has another endpoint, POST /github-read-token (githubread.go):
| Caller | Authorization: Bearer <projected SA token>, audience github-read-token, checked by TokenReview. Only the usernames in githubRead.callers (env GITHUB_READ_CALLERS; default system:serviceaccount:platform-system:glidepath-api). Anyone else is 403, and an empty list 503. An app pipeline is not on it: it has /github-installation-token. |
| Body | {"owner": "<owner>", "repo": "<repo>", "app": "<app>"}; for the tenants repo, {"owner": "<owner>", "repo": "<repo>"} with no app |
| Repo rule | repo must be <app>, gitops-<app> or gitops-infra-<app>, and a PaC Repository CR in app-<app>-cicd or infra-<app>-cicd must map to it (the same name rule as /github-installation-token) with a spec.url under owner. Otherwise 403. |
| Token | An installation token with repositories: [<repo>] and permissions: {contents: read, pull_requests: read, metadata: read} (githubapp.MintRepoReadToken). It cannot push, open, comment on or merge a PR, or reach another repo. |
| Answer | 200 {"token": "...", "expiresAt": "..."}, or {"error": "..."} with 400, 401, 403, 502 or 503. The log line names the caller, repo and app, never the token. |
glidepath-api mints one per repo per request and drops it with the request (never cached, logged or returned), the same never-persisted shape as the per-request registry credential its evidence reads use.
The agent installation token
Clearance’s repo.pr.open opens PRs as the agents’ own GitHub App (hangar-autopilot-agents, hangar decision D4), never
as the platform App, so an agent’s PR is attributable to an agent. Clearance holds no GitHub credential and no App key:
it asks the broker for a token per PR, POST /agent-installation-token (agenttoken.go):
| Caller | Authorization: Bearer <projected SA token>, audience agent-installation-token, checked by TokenReview. Only the usernames in agentToken.callers (env AGENT_TOKEN_CALLERS; default system:serviceaccount:autopilot-system:clearance). Anyone else is 403, an empty list 503. |
| Body | {"owner": "<owner>", "repo": "<repo>", "app": "<app>"} |
| Repo rule | /github-read-token’s, exactly: an onboarded app’s own repo with a PaC Repository CR mapping to it. A cluster repo (gitops-hangar-dev) or a platform repo is 403 even if the App is installed there. Clearance’s per-agent repos.allow is checked first; this is the second check. One exception, the tenants repo (AGENT_PIN_TENANTS_REPO, the chart’s tenantsRepoUrl, the repo /agent-pin pins into): no app owns it and it has no Repository CR, but Clearance’s xr.request opens its PRs, so a request for exactly that owner/repo with no app is accepted for the same callers and gets the same down-scoped token. Unset, it is refused like any other non-app repo; naming an app with it is 403. |
| Token | repositories: [<repo>], permissions: {contents: write, pull_requests: write, metadata: read}. Never workflows: GitHub refuses a push touching .github/workflows. No checks, administration or bypass: the App opens PRs a person merges. |
| Key | The App’s id and key from the platform’s Infisical project (agentToken.appIdKey/privateKeyKey, default agent-app-id/agent-app-private-key), ExternalSecret agent-app in platform-system, mounted only into the broker at /etc/agent-app (optional; until it syncs the endpoint answers 503, and the next request after it syncs loads it). |
| Answer | 200 {"token": "...", "expiresAt": "..."}, or {"error": "..."} with 400, 401, 403, 502 or 503. The log line names the caller, repo, app and permissions, never the token. |
agentToken.enabled: false (the chart default) renders neither the Secret nor the env; the endpoint then answers 503.
Resilience to transient GitHub errors (2026-09-03)
A GitHub API 503 during gitops-image-bump surfaced two gaps, both now fixed across
every catalog Task that talks to api.github.com, a container registry, or the token
broker:
-
No retry on transient failures. Every such
curlcall now carries--retry 3 --retry-connrefused --max-time 15(the conventioncdevents.shalready used for the CDEvents broker), so a 503/502/504/connection-refused/timeout is retried with backoff instead of failing the Task outright.git clone/git pushagainstgithub.com(curl’s flags don’t reach those) get the same treatment via a smallwith_retrywrapper incatalog/lib/retry.sh. -
Retries weren’t actually safe.
bump-manifest-pr.yamlandopen-release-pr.yamlboth named their PR branch<prefix>-<revision>-$(date +%s)- so even though each Task’s ownpr-urlresult already claimed “(or already-open, on retry)”, a retried run (Tekton task retry, or a re-triggered PipelineRun) always looked like a brand new change and opened a duplicate PR. Both branch names are now deterministic and drop the timestamp:bump-manifest-pr.yamlusesimage-bump-<app-name>-<revision>,open-release-pr.yamlusesrelease-<env>-<revision>(<env>included because a single revision can release to multiple upper envs off the same push - e.g. cicd.yaml declaring both astagingand aprodrelease step - and without it every env collapsed onto the same branch name, so whichever release step ran second just found the first env’s PR “already open” and silently reused its URL instead of promoting its own env; real bug, live-confirmed on checkout-api, fixed 2026-09-04). Both Tasks check for an already-open PR from their exact branch before doing any work, reusing it instead of duplicating it.deliver-onboarding-files.yaml’s two onboarding-resync branches had the same timestamped-branch pattern, but a different fix: there’s no single “revision” to key an onboarding-resync branch on (it regenerates whatever cicd.yaml/the platform’s own templates currently look like, which can differ on every trigger), so skip-if-open isn’t right - a genuinely new template change would go silently uncommitted forever behind a stale open PR. Instead both branches are now static (onboarding-resync, not timestamped), force-pushed with fresh content on every trigger, and only the PR-creation call is guarded against an already-open PR (GitHub’s own create-PR call 422s on a duplicate head branch anyway) - so a repo has at most one open onboarding-resync PR at a time, updated in place rather than accumulating duplicates.
Onboarding an Application’s release stage (per app)
2026-08-16: superseded for any app onboarded through idp (Crossplane-based,
airframe’s ApplicationEnvironment composition) - that composition already
scaffolds gitops-<app-name> with the <cluster>/<env>/values.yaml layout
open-release-pr.yaml now requires (an airframe-application Helm values file, not a raw
Deployment manifest), and its own tenant-onboarding ApplicationSet already creates and
owns the ArgoCD Application generically for every env - steps 2 and 5 below (manually
pushing deployment.yaml, applying release-application.yaml) don’t apply and would
actively conflict with idp’s mechanism. The environment’s deploy.environments entry in cicd.yaml must set
cluster: <cluster> (with tier: flight), matching that
ApplicationEnvironment’s own spec.cluster - see open-release-pr.yaml’s own
2026-08-16 header for the full story. See idp’s own docs for that onboarding path.
The steps below are the ORIGINAL, pre-idp procedure - kept accurate as a historical
record and because it’s still what the platform’s own remaining non-idp demo tenants
(nodejs-demo-app, cicd-flow-test-app) are on, but it no longer produces a working
release for any new app: open-release-pr.yaml hard-requires cluster now and writes
a Helm values file, not the raw deployment.yaml this procedure describes seeding.
This is all one-time setup per app, same spirit as onboarding the app repo itself (see
docs/admin/onboarding-mechanics.md) - not something a developer does per release.
-
Plant the GitHub App credentials into Infisical, once per cluster, not per Application (skip if already done) - 2026-08-19: no longer a
kubectlcopy ofpipelines-as-code-secret.github-app-credsinplatform-systemis now a realExternalSecret(charts/glidepath-control-plane/templates/secretstore/ github-app-creds-external-secret.yaml), synced from the control plane’s own Infisical project (platform-cicd-dev) - see secrets-management.md. Read the GitHub App’s id/private key off the GitHub App itself (App settings page - the.pemyou download when generating a private key, plus the App ID shown there) and plant them asgithub-application-id/github-private-keykeys in Infisical (via the UI/API, never through this chart or committed to this repo); theExternalSecretpicks them up automatically from there.2026-08-23:
pipelines-as-code-secretitself is now also synced from these same two Infisical keys, no longer a one-time manual source to copy out of -gitops-cluster-dev/50-platform-cicd/tekton-operator/ pipelines-as-code-secret-external-secret.yaml, see secrets-management.md’s “What this deliberately is not” section. A cluster rebuild no longer needs either Secret hand-recreated - just this one Infisical plant, once per cluster. -
Create the
gitops-<app-name>repo on GitHub (e.g.gitops-boarding-api) and push<app-name>/staging/deployment.yaml+service.yaml(adapted from the app’s own dev manifests) to it - this is the one part of the gitops repo’s content the platform has no way to generate for you (it doesn’t know your Deployment’s shape).The release-guardrail
.tekton/files (onepull-request-<gate>.yamlper gate in.Values.releaseGuardrails- see release-guardrails.md, with<APP_NAMESPACE>/<APP_NAME>already substituted where a given gate needs them) do not need to be hand-copied -deliver-onboarding-files.yaml’sdeliver-gitops-repo-filesstep delivers them via the same onboarding-resync PR mechanism that delivers the app repo’s own.tekton/files (see onboarding-mechanics.md step 6), as long asplatformIdentity.gitopsRepoUrlwas given at install time and theRepositoryCR from step 4 below already exists. Confirmed live: a fresh gitops repo with nothing but the two manifests above gets every governance file opened as a PR automatically on the next onboarding-resync run. -
Install the PaC GitHub App on the new repo. While there, check its permissions include
Contents: Read & writeandPull requests: Read & write- PaC’s own needs (webhook delivery, checks) may not already include these, since this App is now also doing git operations, not just receiving webhooks.Do this even if the repo is private, and don’t assume installing the App on the app repo already covers it - a GitHub App installed with “selected repositories” only reaches repos explicitly added to it, one at a time, independent of visibility. Skipping this step for a private
gitops-<app-name>repo makesdeliver-onboarding-files’s push fail; the broker’s/github-installation-tokenendpoint now surfaces this as an explicit “confirm the App is installed on<repo>” error (catalog/lib/github-app.sh) rather than an opaque clone/push failure - found live 2026-08-22 againstgitops-checkout-api. -
Nothing to do here anymore - as long as
platformIdentity.gitopsRepoUrlis set,charts/glidepath-app/templates/pipelines-as-code/repository.yamlrenders thegitops-<app-name>PaCRepositoryCR automatically (same file that renders the app repo’s own Repository CR - see onboarding-mechanics.md step 1). Previously a manualkubectl apply, folded into the chart once step 2 above already needsgitopsRepoUrlto be set at install time regardless. -
Apply the ArgoCD template, with
<APP_NAMESPACE>,<APP_NAME>,<GITOPS_REPO_URL>substituted:sed -e 's#<APP_NAMESPACE>#app-boarding-api-cicd#g' -e 's#<APP_NAME>#boarding-api#g' \ -e 's#<GITOPS_REPO_URL>#https://github.com/<org>/gitops-boarding-api#g' \ charts/glidepath-app/templates/argocd/release-application.yaml | kubectl apply -f - -
Configure branch protection on the gitops repo’s
mainbranch (GitHub UI or API - not IaC-managed by this platform, documented here so it isn’t a tribal- knowledge step): require status checks matching everynamecurrently in.Values.releaseGuardrails(charts/glidepath-catalog/values.yaml- todaysast,image-scan,provenance,sbom,values,itsm,qa,policy-validation) to pass, and require 2 approving reviews. Enable “Allow auto-merge” on the repo so a PR merges itself the moment checks + reviews are satisfied, without needing anyone to click Merge. This list changes whenever a gate is added/removed (see release-guardrails.md’s “Branch protection” section) - it is not kept in sync automatically.
Evolving the approval requirement
The required-reviewer count is a branch protection setting, not platform code - once
the governance checks are real (Phase 3) and trusted, drop
required_pull_request_reviews.required_approving_review_count to 0 on the gitops
repo directly. Nothing in the platform needs to change for this; the PR flow becomes
fully automatic (checks-gated only) the moment that setting changes.
An environment that runs no workload is refused (2026-10-10)
rollout.enabled: false (or the older rollout: null) in an environment’s human values says it runs no
service: only Jobs, CronJobs and components. open-release-pr (Flight: <cluster>/base.yaml then
<cluster>/<env>/values.yaml) and deploy-manifests (Ground: glidepath/base.yaml then
glidepath/envs/<env>.yaml) layer those files the way Argo CD does and stop with an error naming the files,
before any PR or commit, rather than release an image that would render nothing (the chart’s release-tracking
guard would fail the sync). Turn the Deployment switch on in Tower’s App Configuration to release there.
charts/glidepath-catalog/tests/workload_check_test.sh covers the layering cases.
Turning a workload off removes it: every environment Application (Ground lower-envs, Flight tenant-onboarding
on each cluster) syncs with prune: true (2026-10-10), so the Rollout, Service, RolloutWatch and ServiceMonitor the
chart stops rendering are deleted on the next sync. A Flight environment that still has a release (releaseTracking
in its release.yaml) does not get that far: the chart’s release-tracking guard fails the render, Argo CD shows a
ComparisonError and keeps what is running until the release is removed or the workload is turned back on.
Governance checks, re-triggering, and break-glass
Every gitops-repo release PR carries one required GitHub Check per gate in
.Values.releaseGuardrails (see the PR body itself, which now documents this directly -
Phase 3 item 8.6, and release-guardrails.md for the mechanism
and current gate list). sast/image-scan/provenance/sbom are real as of Phase 3
items 8.4/8.5/item 2/item 8.7 (see that item’s own “cosign --ca-roots deprecation”
fallout note in provenance-policy.md for a still-unresolved edge
case, not a stub), and so is qa since 2026-10-19 (a check ride read from the promoted
image’s test provenance, testing.md); itsm is real once a change tool is configured (change-management.md) and policy-validation is real since 2026-10-26 (the release evaluated offline against the target cluster’s admission policies, policy-validation.md). All run
independently of each other (image-promotion, which waited on the rest, was retired
2026-10-09 - see that doc’s “Two shapes” section and ADR-0025). With catalog promotion.enabled, a real
image-promotion check runs after the last required gate turns green: it copies the image to Flight
(registries.md, ADR-0029).
Re-running a failed check: comment /retest <check-name>- on the PR (e.g. /retest sast-; the dash is part of the name - PaC matches the PipelineRun’s generateName, and
/retest sast answers “cannot find the targeted pipelinerun”, confirmed live 2026-10-19)
to re-run just that one, or /retest alone to re-run every gate in
.Values.releaseGuardrails - PaC’s own native comment mechanism, no platform code
involved. A bare /retest works only on repos re-synced after 2026-11-01: until then each
check’s on-cel-expression began has(body.pull_request), which a /retest comment (an
issue_comment webhook, no pull_request key) never satisfies, so PaC skipped every check.
The templates now match on PaC’s event, target_branch and source_branch, which PaC
fills from the PR for a comment too. A named /retest <check>- never evaluates the CEL, so
it always worked. A webhook that arrives while the PaC controller is down is lost: GitHub
does not redeliver it, so comment again once the controller is Running. Fix the underlying issue first if the failure is real; re-running only
re-evaluates the check, it does not bypass it.
Each check also posts its own real PR comment on top of the GitHub Check PaC already
reports natively (catalog/tasks/comment-pr-check-result.yaml, 2026-08-10) - failure
detail (a live pod-log excerpt), a gate-specific recommendation, and the exact /retest <gate> command on failure; a short confirmation on success. It edits its own prior
comment on a gate in place (a hidden <!-- platform-cicd:gate:<name> --> marker) rather
than always appending, so re-verifying an unchanged commit updates the existing comment
instead of spamming a duplicate. Real race, found live 2026-08-12: since every
commit on a release PR gets independently re-verified, any two check runs for the same
gate landing close enough together (originally: open-release-pr.yaml’s own two commits,
back when it made two - see multi-cluster.md; now, e.g. a /retest <gate> racing a webhook redelivery) can both read “no existing comment” before either
posts - two comments, not deduped. Fixed without a lock: after posting, re-list this
gate’s own comments and collapse to the single earliest (lowest id) survivor - every
racing run computes the same decision off the same eventually-consistent list, so they
converge regardless of which one “wins” (a second run’s delete of an already-deleted
duplicate is expected and tolerated).
Update, 2026-08-31: open-release-pr.yaml now pushes exactly one commit per release
PR - the second commit (outcome-tracking data, needing the PR’s own URL) was found to be
re-triggering every governance gate a second time on every single release, for zero
informational gain. pr-url/pr-created-at are resolved a different way now (a
dev-cluster ConfigMap, read by release-outcome-notify.yaml’s resolve-release-tracking
Task - see multi-cluster.md); this dedup logic stays in place regardless, since the race
it guards against isn’t specific to that removed second commit.
Break-glass (decided with the user, 2026-08-05): who can force a release through a failing required check, and how it’s made visible.
- Who: a dedicated GitHub team (e.g.
platform-admins), not repo/org admins generally - narrower, purpose-specific access. This is a manual GitHub configuration step, not something this platform’s code performs: create the team, add its intended members, then on the gitops repo’s branch protection rule formain, enable “Allow specified actors to bypass required pull requests” (or the equivalent “Do not require approvals/status checks for administrators” toggle, depending on which GitHub plan/UI is in use) scoped to that team. Matches this platform’s existing precedent for branch protection itself (docs/release.md’s own “Evolving the approval requirement” section above) - a manual, out-of-band GitHub setting, not platform IaC. - Audit: GitHub’s own merge/audit-log record is real and already visible on the PR,
but a security-relevant bypass shouldn’t depend on someone thinking to go check it -
charts/glidepath-catalog/templates/tasks/detect-bypass-merge.yamlfires after every PR merge (via the.tekton/pull-request-merged.yamltrigger) and posts a real Slack alert (sameslack-webhook-urlSecret/design language as every other notification, but not gated onnotifications.slack.enabled- a bypass alert isn’t a routine preference) whenever a PR merged despite one of the four checks not showingsuccess. A normal, fully-green merge produces no alert - this is deliberately quiet in the common case. Verified live against real GitHub data (not merely the code path): a real merged PR with all checks green correctly produced no alert; a real PR with a genuinely failingprovenance, fed synthetically asmerged=true(no actual bypass has happened yet on this repo), correctly detected it and posted a real, visible Slack message. Correction, 2026-10-06: the trigger described below never fired. It matched thepull_request“closed” event, and Pipelines-as-Code handles a closed PR itself (cancelling its in-flight runs, logging “no pipelinerun found for repository … and pullRequest N”) without matching any template against it; found live while checking the release record’s merge signal. The trigger is now the push to main the merge produces, andresolve-merged-pr.yamlturns the pushed commit into the PR number (see that Task). The synthetic verification above exercised the Task, not the trigger. Gitops repos need the new.tekton/pull-request-merged.yamldelivered by an onboarding resync. Still not fully verified (a real PR merge with an actual bypass hasn’t been exercised), but a real, related bug WAS found and fixed live 2026-08-12 while investigating something else: PaC evaluates a Pipeline’son-cel-expressionagainst every webhook delivery for the repo once one is present, not just events shaped like what the expression expects - confirmed live via a genuine push webhook (a release PR’s own merge fires one, alongside thepull_requestevent) throwing a real CEL “no such key: action” error against this file’s barebody.action == "closed", reported back as a false-alarm bot comment (“errors in your PipelineRun template”) on the very PR that had just successfully merged. Fixed withhas(body.action) && body.action == "closed"-has()short-circuits the rest of the expression when the event isn’t PR-shaped, same fix applied to all four governance-check files below (they had the identical bare-field pattern onbody.pull_request.*). Doesn’t confirm the intended bypass-detection behavior itself, but does confirm this Trigger’s CEL now survives contact with real, mixed webhook traffic instead of erroring on it.
Verification
- Push to the app repo, let it flow through build->test->deploy->release for real.
- A PR should appear on
gitops-<app-name>with the correct image reference, and every governance check in.Values.releaseGuardrailsshould show up in the PR’s checks list (each individually re-runnable, e.g. via/retest sast-PR comment) - all independent of each other. - Security check: confirm
/github-installation-tokengenuinely rejects a request for a repo the caller’s Application doesn’t own (test fromplatform-cicd-demo’s SA requesting a different, unrelatedgitops-*repo name - should get a 403). - After merge:
kubectl get application nodejs-demo-app-staging -n argocdshowsSynced/Healthy, and the running pod inplatform-cicd-demo-stagingis on the new image - confirmed viakubectl get pods -n platform-cicd-demo-staging -o jsonpath='{.items[0].spec.containers[0].image}'- with no directkubectlfrom any Tekton Task in that path.