New to Kestra?
Use blueprints to kickstart your first workflows.
Keep the CA bundle your Kestra scripts trust in step with Mozilla, with SHA-256 checks, root diffs, Google Chat review of removals and rollback.
id: mozilla-ca-bundle-refresh
namespace: company.team
inputs:
- id: action
type: SELECT
displayName: Action
description: refresh pulls the Mozilla root store from curl.se and publishes it
when it changed. rollback puts back the version that was published before
the current one.
values:
- refresh
- rollback
defaults: refresh
- id: bundle_url
type: STRING
displayName: Mozilla CA bundle URL
description: curl's extract of the Mozilla root store. Keep the default to
follow the newest extract, or pin a dated one such as
https://curl.se/ca/cacert-2026-10-20.pem. Its published .sha256 file is
checked before anything is published.
defaults: https://curl.se/ca/cacert.pem
validator: ^https://curl\.se/ca/cacert[A-Za-z0-9._-]*\.pem$
- id: bundle_path
type: STRING
displayName: Published bundle (namespace file)
description: The namespace file that script tasks point SSL_CERT_FILE or
REQUESTS_CA_BUNDLE at.
defaults: trust/cacert.pem
validator: ^(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*$
- id: extra_roots_path
type: STRING
displayName: Extra roots (namespace file)
description: Optional PEM file in this namespace with roots Mozilla does not
ship, such as a TLS-inspecting proxy or an internal CA. They are appended
to the Mozilla roots, and a comment line above each certificate names it
in the diff. If the file does not exist, the bundle holds Mozilla's roots
only.
defaults: trust/extra-roots.pem
validator: ^(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*$
- id: history_dir
type: STRING
displayName: Archive folder (namespace files)
description: Every published version is also kept here, named by Mozilla date
and SHA-256, so a rollback never needs the network.
defaults: trust/history
validator: ^(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*$
- id: approval_policy
type: SELECT
displayName: When to ask for a review
description: removals_or_downgrade asks before publishing a bundle that drops a
root or is older than the current one, and publishes additions on its own.
any_change asks every time. never publishes without asking. After a
rollback, every change waits for review until a new version is published,
unless the policy is never.
values:
- removals_or_downgrade
- any_change
- never
defaults: removals_or_downgrade
- id: review_timeout
type: DURATION
displayName: Review timeout
description: Without a decision in this time, the current bundle stays in place.
defaults: PT24H
- id: notify
type: BOOL
displayName: Post to Google Chat
description: Send review requests, release notes and failures to the Google Chat
webhook stored in the GOOGLE_CHAT_WEBHOOK_URL secret. When false, the
messages stay in the execution outputs.
defaults: true
- id: kestra_url
type: STRING
displayName: Kestra UI URL
description: Base URL used to link the review request to this execution.
defaults: http://localhost:8080
triggers:
- id: weekly
type: io.kestra.plugin.core.trigger.Schedule
description: curl republishes the extract when Mozilla changes its root store, a
few times a year. A weekly run costs one 200 KB download and ends without
publishing when nothing changed.
cron: "0 6 * * 1"
- id: on_demand
type: io.kestra.plugin.core.trigger.Webhook
description: Start a refresh or a rollback from CI or a runbook by POSTing
{"action":"refresh"} or {"action":"rollback"}. The body only chooses the
action; the source URL and the paths always come from the flow inputs.
key: "{{ secret('CA_BUNDLE_WEBHOOK_KEY') }}"
tasks:
- id: request
type: io.kestra.plugin.core.output.OutputValues
description: Resolve the action once and take a snapshot of the published
version from the KV store, so every later step compares against the same
state.
values:
action: "{{ trigger.body.action ?? inputs.action }}"
state_key: "{{ 'ca_bundle_' ~ (inputs.bundle_path | slugify) }}"
state: "{{ (kv('ca_bundle_' ~ (inputs.bundle_path | slugify),
errorOnMissing=false) ?? {}) | toJson }}"
- id: dispatch
type: io.kestra.plugin.core.flow.Switch
value: "{{ outputs.request.values.action }}"
cases:
refresh:
- id: fetch
type: io.kestra.plugin.core.flow.Parallel
description: The bundle, its published SHA-256 and your extra roots are
independent, so they load side by side.
tasks:
- id: mozilla_bundle
type: io.kestra.plugin.core.http.Download
uri: "{{ inputs.bundle_url }}"
retry:
type: constant
interval: PT30S
maxAttempts: 3
- id: mozilla_checksum
type: io.kestra.plugin.core.http.Request
uri: "{{ inputs.bundle_url }}.sha256"
retry:
type: constant
interval: PT30S
maxAttempts: 3
- id: extra_roots
type: io.kestra.plugin.core.namespace.DownloadFiles
description: Returns no file when the extra roots file does not exist.
namespace: "{{ flow.namespace }}"
files:
- "{{ inputs.extra_roots_path }}"
- id: check_checksum
type: io.kestra.plugin.core.execution.Fail
description: A truncated or corrupted download never reaches the namespace.
condition: "{{ (read(outputs.mozilla_bundle.uri) | sha256) !=
(outputs.mozilla_checksum.body | trim | split(' ') | first) }}"
errorMessage: "The downloaded bundle does not match the SHA-256 curl publishes
for {{ inputs.bundle_url }}. Nothing was published."
- id: candidate
type: io.kestra.plugin.core.storage.Write
description: The bundle to publish, Mozilla's roots followed by your extra
roots. Without extra roots it is byte-identical to curl's file, so
its SHA-256 matches the one curl publishes.
extension: .pem
content: "{{ read(outputs.mozilla_bundle.uri) }}{% if outputs.extra_roots.files
| length > 0 %}{{ read(outputs.extra_roots.files |
jq('to_entries[0].value') | first) }}{% endif %}"
- id: inventory
type: io.kestra.plugin.core.output.OutputValues
description: >
One entry per root: its name and the SHA-256 of its base64
certificate text, so a root is matched across versions even when
curl changes the comment lines around it (newer extracts add a "#
Fingerprint" line). Literal splits only: regex splits recurse once
per match in the jq engine and can overflow the worker's stack on a
bundle of 150 roots.
values:
sha256: "{{ read(outputs.candidate.uri) | sha256 }}"
mozilla_date: >-
{%- set raw = {'pem': read(outputs.mozilla_bundle.uri)} |
jq('[.pem | capture("Certificate data from Mozilla [^:\\n]+:
(?<d>[^\\n]+)")] | (first // {d: ""}).d | split(" ") |
map(select(length > 0)) | join(" ")') | first -%} {{ raw == '' ?
'' : (raw | date("yyyy-MM-dd'T'HH:mm:ss'Z'", existingFormat='EEE
MMM d HH:mm:ss yyyy zzz', timeZone='UTC')) }}
roots: |-
{%- set parsed = {'pem': read(outputs.candidate.uri)} | jq('
.pem
| split("-----END CERTIFICATE-----")
| map(select(test("-----BEGIN CERTIFICATE-----")))
| to_entries
| map(
.key as $k
| (.value | split("-----BEGIN CERTIFICATE-----")) as $p
| ($p[0] | split("\n") | map(sub("^\\s+"; "") | sub("\\s+$"; ""))) as $lines
| {
name: (
([$lines[] | select(length > 0 and (startswith("#") | not) and (test("^=+$") | not))] | last)
// ([$lines[] | select(startswith("#")) | sub("^#+\\s*"; "") | select(length > 0 and (startswith("Fingerprint") | not))] | last)
// ("unnamed root " + (($k + 1) | tostring))
),
body: ($p[1] | split("\n") | join("") | split("\r") | join("") | split(" ") | join(""))
}
)
') | first -%}
[{%- for root in parsed -%}{"n": {{ root.name | toJson }}, "id": "{{ root.body | sha256 }}"}{%- if not loop.last -%},{%- endif -%}{%- endfor -%}]
- id: check_format
type: io.kestra.plugin.core.execution.Fail
description: If curl changes the file layout so that the date or the
certificates can no longer be read, stop instead of publishing a
guess.
condition: "{{ outputs.inventory.values.mozilla_date == '' or
(fromJson(outputs.inventory.values.roots) | length) == 0 }}"
errorMessage: "Could not read the Mozilla date or any certificate from {{
inputs.bundle_url }}. Nothing was published."
- id: diff
type: io.kestra.plugin.core.output.OutputValues
description: >
Compare the candidate with the published version: roots added,
removed or renamed, and the kind of change. A bundle whose Mozilla
date is older than the published one is a downgrade, because it
would bring back roots Mozilla has already removed. A new extract
with the same roots, where only curl's header changed, is
SAME_ROOTS.
values:
archive_path: "{{ inputs.history_dir }}/cacert-{{
outputs.inventory.values.mozilla_date | slice(0, 10) }}-{{
outputs.inventory.values.sha256 | slice(0, 12) }}.pem"
result: |-
{{ {
'old': fromJson(outputs.request.values.state).current ?? {},
'new': fromJson(outputs.inventory.values.roots),
'new_sha': outputs.inventory.values.sha256,
'new_date': outputs.inventory.values.mozilla_date,
'policy': inputs.approval_policy
} | jq('
. as $in
| ($in.old.roots // []) as $old
| $in.new as $new
| ($old | map(.id)) as $oid
| ($new | map(.id)) as $nid
| [$new[] | select(.id as $i | any($oid[]; . == $i) | not) | .n] as $added
| [$old[] | select(.id as $i | any($nid[]; . == $i) | not) | .n] as $removed
| [$new[] | . as $r | $old[] | select(.id == $r.id and .n != $r.n) | (.n + " -> " + $r.n)] as $renamed
| (if $in.old.sha256 == null then "FIRST_PUBLISH"
elif $in.old.sha256 == $in.new_sha then "UNCHANGED"
elif $in.new_date < ($in.old.mozilla_date // "") then "DOWNGRADE"
elif ($removed | length) > 0 then "REMOVALS"
elif ($added | length) > 0 then "ADDITIONS"
else "SAME_ROOTS" end) as $change
| {
change: $change,
review: (if $change == "UNCHANGED" or $in.policy == "never" then "no"
elif $in.old.pinned == true or $in.policy == "any_change" or $change == "REMOVALS" or $change == "DOWNGRADE" then "yes"
else "no" end),
pinned: ($in.old.pinned == true),
added: $added,
removed: $removed,
renamed: $renamed,
roots: ($new | length),
previous_roots: ($old | length),
added_list: ($added[0:40] | join(", ")),
added_more: ([($added | length) - 40, 0] | max),
removed_list: ($removed[0:40] | join(", ")),
removed_more: ([($removed | length) - 40, 0] | max),
old_date: (($in.old.mozilla_date // "none")[0:10]),
new_date: ($in.new_date[0:10])
}
') | first | toJson }}
- id: label_change
type: io.kestra.plugin.core.execution.Labels
description: Lets you filter executions by the kind of change they found.
labels:
ca_bundle_change: "{{ fromJson(outputs.diff.values.result).change }}"
mozilla_store: "{{ outputs.inventory.values.mozilla_date | slice(0, 10) }}"
- id: review
type: io.kestra.plugin.core.flow.If
description: Removals and downgrades wait for a person by default, because an
endpoint that still chains to a removed root stops working for every
script in the namespace.
condition: "{{ fromJson(outputs.diff.values.result).review == 'yes' }}"
then:
- id: review_message
type: io.kestra.plugin.core.output.OutputValues
values:
text: |-
{%- set d = fromJson(outputs.diff.values.result) -%}
*Review needed: root CA bundle for `{{ flow.namespace }}`* ({{ d.change }})
`{{ inputs.bundle_path }}`: Mozilla root store {{ d.old_date }} -> {{ d.new_date }}, {{ d.previous_roots }} -> {{ d.roots }} roots.
{% if d.removed | length > 0 %}Removes {{ d.removed | length }}: {{ d.removed_list }}{% if d.removed_more > 0 %} and {{ d.removed_more }} more{% endif %}
Scripts calling an endpoint that still chains to a removed root will fail TLS once this is published.
{% endif %}{% if d.added | length > 0 %}Adds {{ d.added | length }}: {{ d.added_list }}{% if d.added_more > 0 %} and {{ d.added_more }} more{% endif %}
{% endif %}{% if d.pinned %}The current bundle was restored by a rollback, so every change waits for review.
{% endif %}Decide in Kestra: {{ inputs.kestra_url }}/ui/main/executions/{{ flow.namespace }}/{{ flow.id }}/{{ execution.id }}
Without a decision before the review timeout, the current bundle stays.
- id: ask_reviewer
type: io.kestra.plugin.googleworkspace.chat.GoogleChatIncomingWebhook
runIf: "{{ inputs.notify }}"
url: "{{ secret('GOOGLE_CHAT_WEBHOOK_URL') }}"
payload: "{{ {'text': outputs.review_message.values.text} | toJson }}"
- id: wait_for_review
type: io.kestra.plugin.core.flow.Pause
description: Resume from the execution page. On timeout the flow resumes with no
decision and keeps the current bundle.
pauseDuration: "{{ inputs.review_timeout }}"
behavior: RESUME
onResume:
- id: decision
type: SELECT
displayName: Decision
values:
- Publish the new bundle
- Keep the current bundle
defaults: Keep the current bundle
- id: note
type: STRING
displayName: Note for the release message
required: false
- id: decide
type: io.kestra.plugin.core.output.OutputValues
values:
outcome: >-
{%- set d = fromJson(outputs.diff.values.result) -%} {%- if
d.change == 'UNCHANGED' -%}UNCHANGED {%- elseif d.review == 'no'
or (outputs.wait_for_review.onResume.decision ?? '') == 'Publish
the new bundle' -%}PUBLISH {%- else -%}HOLD{%- endif -%}
- id: apply
type: io.kestra.plugin.core.flow.Switch
value: "{{ outputs.decide.values.outcome }}"
cases:
PUBLISH:
- id: check_state
type: io.kestra.plugin.core.execution.Fail
description: If another run, such as a rollback, changed the published version
while this one waited for review, publishing now would undo
it. Stop, and let the next run compare again.
condition: "{{ ((kv(outputs.request.values.state_key, errorOnMissing=false) ??
{}).current.sha256 ?? '') !=
(fromJson(outputs.request.values.state).current.sha256 ?? '')
}}"
errorMessage: "The published version of {{ inputs.bundle_path }} changed while
this run was waiting for review. Nothing was published; run
the flow again."
- id: publish
type: io.kestra.plugin.core.namespace.UploadFiles
description: Overwrite the live bundle and keep an archive copy for rollbacks.
namespace: "{{ flow.namespace }}"
filesMap:
"{{ inputs.bundle_path }}": "{{ outputs.candidate.uri }}"
"{{ outputs.diff.values.archive_path }}": "{{ outputs.candidate.uri }}"
- id: read_back
type: io.kestra.plugin.core.namespace.DownloadFiles
namespace: "{{ flow.namespace }}"
files:
- "{{ inputs.bundle_path }}"
- id: check_published
type: io.kestra.plugin.core.execution.Fail
description: The state is only recorded once the namespace serves exactly the
bytes that were reviewed.
condition: "{{ (outputs.read_back.files | length) == 0 or
(read(outputs.read_back.files | jq('to_entries[0].value') |
first) | sha256) != outputs.inventory.values.sha256 }}"
errorMessage: "{{ inputs.bundle_path }} does not hold the bundle that was just
uploaded. The KV state still points at the previous version."
- id: record
type: io.kestra.plugin.core.kv.Set
description: The published version becomes current; the one it replaced stays as
the rollback target.
key: "{{ outputs.request.values.state_key }}"
kvType: JSON
value: "{{ {'current': {'sha256': outputs.inventory.values.sha256,
'mozilla_date': outputs.inventory.values.mozilla_date,
'source': inputs.bundle_url, 'archive':
outputs.diff.values.archive_path, 'published_at': now() |
date(\"yyyy-MM-dd'T'HH:mm:ss'Z'\", timeZone='UTC'),
'execution': execution.id, 'roots':
fromJson(outputs.inventory.values.roots)}, 'previous':
fromJson(outputs.request.values.state).current ?? null} |
toJson }}"
- id: release_message
type: io.kestra.plugin.core.output.OutputValues
values:
text: |-
{%- set d = fromJson(outputs.diff.values.result) -%}
*Root CA bundle published to `{{ flow.namespace }}/{{ inputs.bundle_path }}`*
Mozilla root store as of {{ d.new_date }}, {{ d.roots }} roots, SHA-256 {{ outputs.inventory.values.sha256 | slice(0, 16) }}...
{% if d.change == 'FIRST_PUBLISH' %}First version published by this flow.{% elseif d.change == 'SAME_ROOTS' %}Same roots as before; only curl's file changed.{% else %}{{ d.added | length }} added, {{ d.removed | length }} removed{% if d.renamed | length > 0 %}, {{ d.renamed | length }} renamed{% endif %}.{% endif %}
{% if d.removed | length > 0 and d.change != 'FIRST_PUBLISH' %}Removed: {{ d.removed_list }}{% if d.removed_more > 0 %} and {{ d.removed_more }} more{% endif %}
{% endif %}{% if d.added | length > 0 and d.change != 'FIRST_PUBLISH' %}Added: {{ d.added_list }}{% if d.added_more > 0 %} and {{ d.added_more }} more{% endif %}
{% endif %}{% if outputs.wait_for_review is defined %}Approved after review{% if outputs.wait_for_review.onResume.note ?? false %}: {{ outputs.wait_for_review.onResume.note }}{% endif %}.
{% endif %}Archived as {{ outputs.diff.values.archive_path }}. Roll back with action=rollback.
- id: announce
type: io.kestra.plugin.googleworkspace.chat.GoogleChatIncomingWebhook
description: A new extract with the same roots is published without a message.
runIf: "{{ inputs.notify and fromJson(outputs.diff.values.result).change !=
'SAME_ROOTS' }}"
url: "{{ secret('GOOGLE_CHAT_WEBHOOK_URL') }}"
payload: "{{ {'text': outputs.release_message.values.text} | toJson }}"
HOLD:
- id: hold_message
type: io.kestra.plugin.core.output.OutputValues
values:
text: |-
{%- set d = fromJson(outputs.diff.values.result) -%}
{%- set cur = fromJson(outputs.request.values.state).current ?? {} -%}
*Root CA bundle update held for `{{ flow.namespace }}/{{ inputs.bundle_path }}`*
The update to the Mozilla root store of {{ d.new_date }} ({{ d.change }}) was not published ({{ outputs.wait_for_review.onResume.decision ?? 'no decision before the timeout' }}{% if outputs.wait_for_review.onResume.note ?? false %}: {{ outputs.wait_for_review.onResume.note }}{% endif %}).
{% if cur.sha256 ?? false %}The bundle in place since {{ cur.published_at | slice(0, 10) }} stays (Mozilla {{ cur.mozilla_date | slice(0, 10) }}, SHA-256 {{ cur.sha256 | slice(0, 16) }}...).{% else %}Nothing is published yet.{% endif %} The next run asks again.
- id: announce_hold
type: io.kestra.plugin.googleworkspace.chat.GoogleChatIncomingWebhook
runIf: "{{ inputs.notify }}"
url: "{{ secret('GOOGLE_CHAT_WEBHOOK_URL') }}"
payload: "{{ {'text': outputs.hold_message.values.text} | toJson }}"
UNCHANGED:
- id: already_current
type: io.kestra.plugin.core.log.Log
message: "The KV store already records this bundle for {{ inputs.bundle_path }}
(SHA-256 {{ outputs.inventory.values.sha256 }}, Mozilla {{
outputs.inventory.values.mozilla_date }}). Nothing to
publish."
rollback:
- id: check_rollback
type: io.kestra.plugin.core.execution.Fail
condition: "{{ (fromJson(outputs.request.values.state).previous ?? null) == null
}}"
errorMessage: "Nothing to roll back to: this flow has not published an earlier
version of {{ inputs.bundle_path }}."
- id: label_rollback
type: io.kestra.plugin.core.execution.Labels
labels:
ca_bundle_change: ROLLBACK
- id: restore_source
type: io.kestra.plugin.core.namespace.DownloadFiles
description: The rollback target comes from the archive in namespace files, not
from the network.
namespace: "{{ flow.namespace }}"
files:
- "{{ fromJson(outputs.request.values.state).previous.archive }}"
- id: check_archive
type: io.kestra.plugin.core.execution.Fail
condition: "{{ (outputs.restore_source.files | length) == 0 or
(read(outputs.restore_source.files | jq('to_entries[0].value') |
first) | sha256) !=
fromJson(outputs.request.values.state).previous.sha256 }}"
errorMessage: "The archived bundle {{
fromJson(outputs.request.values.state).previous.archive }} is
missing or was modified. Nothing was rolled back."
- id: restore
type: io.kestra.plugin.core.namespace.UploadFiles
namespace: "{{ flow.namespace }}"
filesMap:
"{{ inputs.bundle_path }}": "{{ outputs.restore_source.files |
jq('to_entries[0].value') | first }}"
- id: read_back_restored
type: io.kestra.plugin.core.namespace.DownloadFiles
namespace: "{{ flow.namespace }}"
files:
- "{{ inputs.bundle_path }}"
- id: check_restored
type: io.kestra.plugin.core.execution.Fail
condition: "{{ (outputs.read_back_restored.files | length) == 0 or
(read(outputs.read_back_restored.files | jq('to_entries[0].value') |
first) | sha256) !=
fromJson(outputs.request.values.state).previous.sha256 }}"
errorMessage: "{{ inputs.bundle_path }} does not hold the restored bundle. The
KV state still points at the newer version."
- id: record_rollback
type: io.kestra.plugin.core.kv.Set
description: Swap current and previous, so a second rollback returns to the
newer version. The restored version is pinned, so the next refresh
asks before publishing anything else.
key: "{{ outputs.request.values.state_key }}"
kvType: JSON
value: "{{ {'state': fromJson(outputs.request.values.state), 'now': now() |
date(\"yyyy-MM-dd'T'HH:mm:ss'Z'\", timeZone='UTC'), 'execution':
execution.id} | jq('{current: (.state.previous + {pinned: true,
published_at: .now, execution: .execution}), previous:
.state.current}') | first | toJson }}"
- id: rollback_message
type: io.kestra.plugin.core.output.OutputValues
values:
text: |-
{%- set prev = fromJson(outputs.request.values.state).previous -%}
{%- set cur = fromJson(outputs.request.values.state).current -%}
*Root CA bundle rolled back in `{{ flow.namespace }}/{{ inputs.bundle_path }}`*
Now serving Mozilla {{ prev.mozilla_date | slice(0, 10) }} ({{ prev.roots | length }} roots, SHA-256 {{ prev.sha256 | slice(0, 16) }}...) instead of Mozilla {{ cur.mozilla_date | slice(0, 10) }} ({{ cur.roots | length }} roots).
The next refresh asks before publishing anything else. Run action=rollback again to return to the newer version.
- id: announce_rollback
type: io.kestra.plugin.googleworkspace.chat.GoogleChatIncomingWebhook
runIf: "{{ inputs.notify }}"
url: "{{ secret('GOOGLE_CHAT_WEBHOOK_URL') }}"
payload: "{{ {'text': outputs.rollback_message.values.text} | toJson }}"
defaults:
- id: unknown_action
type: io.kestra.plugin.core.execution.Fail
errorMessage: Unknown action. Use refresh or rollback.
- id: summary
type: io.kestra.plugin.core.output.OutputValues
description: Read the state back from the KV store, so the outputs describe what
the namespace serves now.
values:
outcome: "{{ outputs.decide.values.outcome ?? 'ROLLED_BACK' }}"
live_sha256: "{{ (kv(outputs.request.values.state_key, errorOnMissing=false) ??
{}).current.sha256 ?? '' }}"
live_mozilla_date: "{{ (kv(outputs.request.values.state_key,
errorOnMissing=false) ?? {}).current.mozilla_date ?? '' }}"
errors:
- id: alert_failure
type: io.kestra.plugin.googleworkspace.chat.GoogleChatIncomingWebhook
description: A failed refresh or rollback is reported with the version the KV
store still records, so a stale trust store is never silent.
runIf: "{{ inputs.notify }}"
url: "{{ secret('GOOGLE_CHAT_WEBHOOK_URL') }}"
payload: "{{ {'text': '*Root CA bundle ' ~ ((outputs.request.values.action ??
'') == 'rollback' ? 'rollback' : 'refresh') ~ ' failed* for `' ~
flow.namespace ~ '/' ~ inputs.bundle_path ~ '`. The KV store records
SHA-256 ' ~ ((kv('ca_bundle_' ~ (inputs.bundle_path | slugify),
errorOnMissing=false) ?? {}).current.sha256 ?? 'none') ~ ' as published.
Execution: ' ~ inputs.kestra_url ~ '/ui/main/executions/' ~ flow.namespace
~ '/' ~ flow.id ~ '/' ~ execution.id} | toJson }}"
outputs:
- id: outcome
type: STRING
value: "{{ outputs.summary.values.outcome ?? 'FAILED' }}"
- id: live_sha256
type: STRING
value: "{{ outputs.summary.values.live_sha256 ?? '' }}"
- id: live_mozilla_date
type: STRING
value: "{{ outputs.summary.values.live_mozilla_date ?? '' }}"
- id: change
type: STRING
value: "{{ fromJson(outputs.diff.values.result ?? '{}').change ??
((outputs.request.values.action ?? '') == 'rollback' ? 'ROLLBACK' :
'NONE') }}"
Mozilla's root store decides which certificate authorities most TLS clients outside the browser trust, and it keeps moving: between curl's December 2025 and May 2026 extracts Mozilla removed 24 roots, DigiCert Global Root CA among them. A script with an old bundle keeps trusting CAs Mozilla dropped; one that refreshes blindly fails TLS against endpoints that still chain to a removed root; behind a TLS-inspecting proxy it also needs the proxy's root in the same file. This flow turns that file into a reviewed release for every script task in the namespace.
{"action": "refresh"} or {"action": "rollback"}) or a manual run starts it.fetch (Parallel) downloads curl's extract and its .sha256 and reads optional extra roots from trust/extra-roots.pem. A checksum mismatch or an unreadable file stops the run.inventory lists every root by name and by the SHA-256 of its base64 certificate text, and diff compares it with the version recorded in the KV store: FIRST_PUBLISH, ADDITIONS, REMOVALS, DOWNGRADE, SAME_ROOTS or UNCHANGED.review (If) sends removals and downgrades to Google Chat and waits on a Pause for Publish or Keep; no decision keeps the current bundle.apply (Switch) publishes trust/cacert.pem with an archive copy, checks the read-back SHA-256, records the version in the KV store and posts a release note. A run that waited is refused if the published version changed meanwhile.rollback restores the previous version from the archive and pins it, so the next refresh asks before publishing anything else.errors alert naming the version still recorded.plugin-googleworkspace, and HTTPS access to curl.se (no account or API key).trust/extra-roots.pem namespace file, with a comment line above each certificate to name it.GOOGLE_CHAT_WEBHOOK_URL: Google Chat incoming webhook for reviews, release notes and failures (when notify is true).CA_BUNDLE_WEBHOOK_KEY: key that guards the webhook URL.trust/extra-roots.pem if you need your own roots.namespaceFiles:
enabled: true
include:
- trust/cacert.pem
env:
SSL_CERT_FILE: trust/cacert.pem
REQUESTS_CA_BUNDLE: trust/cacert.pem
action: rollback if a published change breaks a call.outcome output.