Runner integration
Reading a backend tells Marolum what the backend recorded. It cannot tell Marolum that a run started, that it was cancelled, or that a preview happened, because Pulumi writes none of that to state. Runner integration closes the gap: the deployment reports its own operation to Marolum as it runs.
Two ways in, with equal standing:
marolum exec: a thin wrapper that runs your Pulumi command and reports for you. One prefix word, three settings.- The HTTP contract underneath it, with its baseline illustrated below. A pipeline that calls it directly submits evidence with the same standing as the wrapper. Use the versioned application contract matching your release for optional extensions.
Runner integration is optional. A backend-only installation can retain the operation history its backend records; runners add previews, lifecycle states, and provenance.
Show a stack link in Pulumi output
If you only want a link from Pulumi output to your stack in Marolum, add this optional snippet
to your Pulumi TypeScript project. It uses the @pulumi/pulumi dependency already in the project;
there is no Marolum helper package or integration token to install. It sends no evidence and
creates no infrastructure resources.
Open the corresponding stack in your own self-hosted Marolum installation and copy its stack link. The hostname and backend/project/stack path are specific to your installation.
Select the matching Pulumi stack and store that non-secret link in its configuration. Replace the entire example URL below with the copied link;
marolum.example.comis a placeholder:pulumi config set marolum:stackUrl 'https://marolum.example.com/#inventory/production/project-scoped/payments/prod' --stack prodSave the following as
marolum-stack-link.tsbeside your program's entry file, then import it from the entry file withimport './marolum-stack-link';.
import * as pulumi from '@pulumi/pulumi';
// Copy the corresponding stack's link from Marolum into this stack's configuration.
try {
const stackUrl = new pulumi.Config('marolum').get('stackUrl');
if (stackUrl) {
const url = new URL(stackUrl);
if (
/[\s\p{Cc}]/u.test(stackUrl) ||
url.protocol !== 'https:' ||
url.username ||
url.password ||
url.search ||
!url.hash.startsWith('#inventory/')
) {
throw new Error('Invalid stack link');
}
void pulumi.log.info(`View stack history: ${url.href}`).catch(() => undefined);
}
} catch {
try {
void pulumi.log
.warn('Marolum stack link unavailable; check marolum:stackUrl.')
.catch(() => undefined);
} catch {
// Optional logging must never prevent the Pulumi program from running.
}
}
- Run
pulumi preview --stack prod. Output includesView stack history:followed by your configured link. Open it and verify that it shows the intended stack. Marolum still requires sign-in and checks the reader's access.
Configure the URL separately for every Pulumi stack. Update it when the installation address or
stack identity changes; copying the link avoids constructing Marolum routes by hand. Use HTTPS,
without credentials or query parameters in the URL. With no setting the snippet logs nothing;
invalid settings produce a generic warning without failing the program. Remove the setting with
pulumi config rm marolum:stackUrl --stack prod to stop logging it.
This link opens stack history. It does not identify a particular operation or assert that evidence
was received. The reporting setup below adds that evidence, and marolum exec can print an exact
operation link when its announcement is accepted.
The promise
Marolum being unavailable never changes the Pulumi command's exit code and never waits indefinitely. Evidence-delivery failure is reported separately, as a warning. The wrapper enforces that structurally: configuration is resolved before anything is spawned, the announcement runs beside the deployment rather than in front of it, every delivery is bounded and then abandoned, and the exit code comes from the child process alone.
What you see when Marolum is down:
marolum exec: evidence delivery failed. The deployment ran unchanged and this warning did not alter its exit code.
marolum exec: announce: network-unreachable
There is no local spool. Undelivered evidence is lost and says so; the backend observer still records the applying run, visibly without the wrapper's assertions.
Before you start
Ask a Marolum administrator for an integration token scoped to the stacks you deploy. The procedure is in Integration tokens. The token:
- carries exactly one permission,
operations:evidence:write; - uses explicit backend, project, and stack patterns, or an administrator-authorized all-stacks scope covering current and future stacks;
- can never read state, history, or transcripts, and is not a session;
- is shown once at issuance and stored only as a digest.
Install marolum exec
The CLI is an immutable, versioned website download matching your image. No access to a private Dekglas repository is required. The release record binds its SHA-256, size, and version to the image, so verify before installing. Node.js 26 and npm are required; the package has no runtime dependencies and no install scripts.
set -eu
MAROLUM_RELEASE_VERSION='<version>'
curl --fail --location --proto '=https' --max-filesize 262144 \
"https://www.dekglas.com/downloads/marolum/v${MAROLUM_RELEASE_VERSION}/release.json" \
--output marolum-release.json
curl --fail --location --proto '=https' --max-filesize 1048576 \
"https://www.dekglas.com/downloads/marolum/v${MAROLUM_RELEASE_VERSION}/marolum-cli-${MAROLUM_RELEASE_VERSION}.tgz" \
--output "marolum-cli-${MAROLUM_RELEASE_VERSION}.tgz"
node --input-type=module - "$MAROLUM_RELEASE_VERSION" <<'NODE'
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
const version = process.argv[2];
const record = JSON.parse(readFileSync('marolum-release.json', 'utf8'));
assert.equal(record.version, version);
assert.equal(record.cli.version, version);
assert.equal(record.cli.archive, `marolum-cli-${version}.tgz`);
const archive = readFileSync(record.cli.archive);
assert.equal(archive.length, record.cli.bytes);
assert.equal(`sha256:${createHash('sha256').update(archive).digest('hex')}`, record.cli.digest);
NODE
npm install --global --offline --ignore-scripts --no-audit --no-fund \
"./marolum-cli-${MAROLUM_RELEASE_VERSION}.tgz"
marolum --version
Stop if download or verification fails. Retain the verified tarball as a CI artifact; do not replace it with an unversioned registry install; no npm-registry package is published.
Configure
export MAROLUM_URL=https://marolum.example.com
export MAROLUM_TOKEN=marolum_it_...
export MAROLUM_BACKEND_ID=production
| Setting | Flag | Meaning |
|---|---|---|
MAROLUM_URL |
None | Base HTTPS address of the installation |
MAROLUM_TOKEN |
None | The integration token |
MAROLUM_TOKEN_FILE |
None | Alternative: a file inside your home directory, outside the working tree |
MAROLUM_BACKEND_ID |
--backend |
The backend id the stack belongs to |
MAROLUM_STACK |
--stack |
Stack name; read from the command's own --stack when omitted |
MAROLUM_PROJECT |
--project |
Project name; read from Pulumi.yaml when omitted |
MAROLUM_STACK_LAYOUT |
--layout |
project-scoped (default) or legacy |
MAROLUM_CAPTURE |
--capture |
none (default) or transcript |
- Credentials. Set exactly one of
MAROLUM_TOKENandMAROLUM_TOKEN_FILE. A token file outside your home directory, or inside the working tree, is refused. The wrapper removes both variables from the environment it hands to Pulumi, so the token never reaches a Pulumi program or provider. - Transport.
httpsis required;httpis admitted only for loopback. Certificate verification is never relaxed; the wrapper refuses to run whenNODE_TLS_REJECT_UNAUTHORIZED=0is set. Supply a private CA withNODE_EXTRA_CA_CERTS. Redirects are not followed and proxy variables are not honored. - Exit codes before the command starts. Misconfiguration exits
78; an unreadable invocation exits64. Both spawn nothing. Once the command starts, the exit code is always the command's own.
Check the connection and token scope
Use this optional setup check only when your installed CLI lists check in
marolum --help and the matching server supports it. Older releases do not provide this endpoint;
follow the frozen guide distributed with your image and CLI.
After configuring the installation address and token, supply the exact target:
marolum check --backend production --project platform --stack prod
For a legacy-layout backend, use --layout legacy --stack prod and omit --project.
The command can also use MAROLUM_BACKEND_ID, MAROLUM_PROJECT, MAROLUM_STACK and
MAROLUM_STACK_LAYOUT. There is no child command from which to infer a stack. It uses the same
credential-file and HTTPS rules as marolum exec.
The check prints the configured destination and target, then makes one request with an eight-second overall limit. It never retries, follows redirects, disables certificate verification, runs an infrastructure command, or creates an operation record. It prints no token or server-provided response text. Keep your configured target names appropriate for the job log.
A successful check means the installation is reachable and the token authorizes evidence submission for that target. An all-stacks token includes future targets, so success does not prove that the stack exists. It also does not prove storage capacity, capture permission, or accepted evidence. Finish setup by checking the first real accepted operation in Marolum.
| Result | Next step |
|---|---|
| Connected | Run the normal workflow and verify its accepted operation. |
| Invalid, expired or revoked credential | Ask an administrator to inspect the token; the response does not distinguish these cases. |
| Target refused | Check its backend, layout, project and stack scope. An older server without the endpoint can also refuse it. |
| TLS verification failed | Correct the certificate or configure the required trusted CA. Do not disable verification. |
| Unreachable, unavailable or timed out | Check the exact address, network path and installation readiness, then retry manually. |
| Rate limited | Wait for the token's request window to reset. |
| Redirect or unsupported response | Use the installation's exact base address and matching CLI/server versions. |
Exit codes are 0 for success, 1 for a failed check, 64 for an invalid invocation, and
78 for refused configuration. Run it as a separate setup or diagnostic step. Do not put it
before deployment in a failure-aborting chain: an optional evidence service must not prevent
the infrastructure command from running.
Rotate an integration token
- Have an administrator issue a replacement with the required target scope and expiry.
- Update the CI secret or protected local token file. Use the separate check to verify the new token and target, then verify a real accepted operation from the updated workflow.
- Let already-running commands finish using their original token.
- Revoke the old token after the handover. If it was exposed, revoke it immediately and accept that evidence delivery from old runs may fail; their infrastructure command keeps its own result.
Never reuse a revoked token, commit either secret, or grant read/admin permissions to make this check pass. The administrator can inspect token status; a runner credential cannot administer tokens or read console data.
Use it
marolum exec -- pulumi up --stack prod --yes
marolum exec -- pulumi preview --stack dev
marolum exec -- pulumi up --stack prod --message "ship the rate limiter" --yes
marolum exec --backend production --stack prod -- pulumi destroy --yes
Name the stack on every command, with the command's own --stack or MAROLUM_STACK. The
wrapper does not read Pulumi's selected stack; without either it exits 78
(stack-name-missing) and runs nothing. MAROLUM_STACK takes precedence over the command's
--stack, so set it only when every command in that environment targets the same stack.
Supported commands: up, update, preview, refresh, destroy, import.
The wrapper appends a marker to the update message (ship the rate limiter [marolum:v1:0b6d…]) and preserves your text exactly. The marker survives into pulumi stack history, which is
how Marolum joins the runner's report with the backend record it observes later. A preview
gets no marker, because a preview writes no history record.
If the marker is stripped, the wrapper is not used, or history is pruned before Marolum sees it, nothing breaks: both sources stand as independent, labelled operations.
Resource-change counts
To report how many resources an operation touched, ask Pulumi for a fresh event log and let the
wrapper read it after the command ends. Pulumi 3.256.0 requires PULUMI_DEBUG_COMMANDS=true
to expose --event-log; set it for each invocation as shown:
PULUMI_DEBUG_COMMANDS=true marolum exec -- pulumi up --yes --skip-preview --event-log /tmp/up-$(date +%s).jsonl
The path must be new for that command, a regular file, at most 16 MiB and 100,000 events. Only normalized counts reach Marolum; no diagnostic or resource payload is uploaded. Without a usable log, the row reads Changes not reported rather than a fabricated zero.
Planned versus actual
Pair a preview with the apply that followed it:
PULUMI_DEBUG_COMMANDS=true marolum exec -- pulumi preview --event-log /tmp/preview-$(date +%s).jsonl
# prints the accepted evidence UUID
PULUMI_DEBUG_COMMANDS=true marolum exec --correlate '<preview-evidence-uuid>' -- pulumi up --yes --skip-preview --event-log /tmp/up-$(date +%s).jsonl
Replace <preview-evidence-uuid> with the exact accepted evidence UUID from the preview.
The console then shows planned and actual counts side by side. Marolum never infers a pair from
timestamps, stack names, or similar counts; the explicit pair is the only trusted correlation.
Runner correlation verification
Use this procedure when completing installation setup or onboarding a new runner. Choose a stack whose commands you are already authorized to run; verification does not authorize an infrastructure change.
- Ask an administrator to issue an integration token with evidence-write authority for the intended backend, project and stack. Store the one-time secret in your CI secret store or a protected file inside your home directory, outside the working tree. Keep transcript capture off unless you have separately chosen to capture it.
- Install the matching public CLI, verify
its release record and archive, and check
marolum --version. This works without access to a private Dekglas repository or the optional GitHub action. - Configure the runner with your installation URL, token and exact backend/project/stack identity. Confirm that Marolum can observe that backend using its own read-only credentials; the evidence token does not grant backend access.
- Run a permitted command with the wrapper, using the examples in Use it. A preview is useful for checking evidence delivery first. Sign in to Marolum as an operator allowed to read this stack and open Operations, or follow the exact operation link if the CLI's announcement was accepted. Confirm the intended backend, project, stack, command and outcome, with Runner (deployment runner) or Runner (developer workflow) evidence. A preview has no backend update record and remains runner-only.
- To check the backend side, wrap an update you already intend and are authorized to perform.
Keep its exact
[marolum:v1:…]marker in the Pulumi update message and retain backend history until Marolum observes it. Confirm thatpulumi stack historyshows the marker and that Operations then lists a Pulumi backend row for the same stack beside the runner row. Marolum records the join between the two but does not display it for a finalized run; it shows only on a runner row that never finalized, as the backend's outcome marked adopted from correlated record. Matching timestamps, a printed stack URL or a successful command exit do not prove that these sources were joined. - If delivery warns, a row is missing or the marker is gone from history, leave verification incomplete. Check token scope/expiry, the configured stack identity, backend diagnostics and whether history still contains the exact marker; use Troubleshooting for failures. Evidence delivery never changes the wrapped command's exit code, so command success alone is insufficient. Revoke a disposable test token after the check; retain only the scoped credential actually needed by the runner.
CI examples
GitHub Actions
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 26
- name: Install the verified Marolum CLI
run: npm install --global --offline --ignore-scripts ./marolum-cli-${MAROLUM_RELEASE_VERSION}.tgz
- name: Deploy
env:
MAROLUM_URL: https://marolum.example.com
MAROLUM_TOKEN: ${{ secrets.MAROLUM_TOKEN }}
MAROLUM_BACKEND_ID: production
PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }}
run: marolum exec -- pulumi up --yes --stack prod
The step fails exactly when pulumi up fails and passes exactly when it passes. Marolum's
availability changes neither.
Inside GitHub Actions, the CLI reports repository, pull request, source and tested revisions,
run, attempt and job identity from GitHub's environment and event file for supported events. It
checks their format; this is runner-reported context, not proof from GitHub that a run or revision
exists. Unsupported events omit this context. If you use a custom checkout, supply its actual HEAD
with --tested-revision; the CLI does not discover that checkout. The CLI reads no GitHub
token and sends no actor, branch or workflow name. The optional action below uses its own GitHub
token to post PR summaries; that permission is separate from CLI evidence delivery.
GitLab CI
deploy:
image: node:26
variables:
MAROLUM_URL: https://marolum.example.com
MAROLUM_BACKEND_ID: production
script:
- npm install --global --offline --ignore-scripts ./marolum-cli-${MAROLUM_RELEASE_VERSION}.tgz
- marolum exec -- pulumi up --yes --stack prod
Set MAROLUM_TOKEN as a masked, protected CI/CD variable. Do not put it in .gitlab-ci.yml.
Both examples assume the download-and-verify steps above ran earlier in the pipeline and left the pinned archive in the job's working directory.
Optional GitHub action
The optional dekglas/marolum-action integration provides CLI setup, Pulumi preview execution
with revision-aware GitHub PR summaries, and optional evidence delivery through the Marolum CLI.
It runs no infrastructure updates and adds no engine support beyond those capabilities. The
repository currently requires access; public action publication is a separate decision and is
not required to use the downloadable CLI. There is no old-name compatibility action.
Preview and PR comments
The repository remains private and no public action version is released. Use an explicitly
reviewed commit when authorized to run it. The example coordinate needs that commit in place of
<reviewed-commit>; it does not imply that a v1 tag exists.
name: preview
on:
pull_request:
permissions:
contents: read
actions: read # verify workflow/run/attempt identity
pull-requests: write
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pulumi/actions@v6 # installs the Pulumi CLI
with:
command: version
- uses: dekglas/marolum-action@<reviewed-commit>
with:
stack-name: production
work-directory: infra
env:
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
# or AWS credentials / passphrase for a self-managed backend login
The workflow must log the Pulumi CLI into your backend before this step, the same way your
existing preview step does (access token, S3 backend credentials, or pulumi login of your
choosing).
Preview inputs
| Input | Default | Purpose |
|---|---|---|
stack-name |
None (required) | Stack passed to pulumi preview --stack |
work-directory |
. |
Directory containing the Pulumi project |
pulumi-command |
pulumi |
The Pulumi executable to invoke |
comment-tag |
the stack name | Distinguishes sticky comments when one PR previews stacks |
max-listed-changes |
50 |
Maximum individual resource rows in the comment (1–200) |
github-token |
${{ github.token }} |
Token used to create or update the comment |
comment-author |
github-actions[bot] |
Login that owns the supplied token’s comments; set for custom tokens or Enterprise bot identities |
marolum-url |
MAROLUM_URL or empty |
Optional console base URL (HTTPS, or HTTP on loopback for evaluation) |
marolum-backend-id |
MAROLUM_BACKEND_ID or empty |
Registered Marolum backend ID; required with the URL |
marolum-layout |
MAROLUM_STACK_LAYOUT or project-scoped |
project-scoped or legacy |
marolum-project-name |
MAROLUM_PROJECT or empty |
Link-only project-scoped mode requires a name; evidence mode can use CLI detection; omit for legacy |
marolum-evidence |
false |
Opt in to evidence delivery through the existing CLI |
marolum-command |
marolum |
Installed CLI executable supporting local result v1 |
marolum-token |
(empty) | Scoped integration token; falls back to MAROLUM_TOKEN |
The link uses console contract v1 and opens the exact backend, layout, project and stack. The
stack name is the final segment of stack-name (so organization/project/production selects
production). Supply the project explicitly; the action does not guess it from a directory.
Configured base paths are preserved. Credentials, queries, fragments, invalid identities and
non-loopback HTTP URLs are refused before preview execution. With no URL, no link is emitted.
Without accepted evidence this is a stack-history link. An accepted operation replaces it with
its exact operation link, even when later delivery is incomplete.
Preview outputs
| Output | Meaning |
|---|---|
summary |
One line, e.g. 3 to create, 1 to update, 2 to delete |
destructive |
true when the plan contains any delete or replace |
comment-url |
URL of the created or updated comment, when one was posted |
comment-status |
posted, skipped-stale, skipped-superseded, failed, or not-a-pull-request |
preview-status |
succeeded or failed, independently of comment delivery |
preview-exit-code |
Preview process exit code, or 1 if valid preview evidence could not be collected |
tested-revision |
Actual project checkout HEAD, when established for a comment or delivery |
evidence-status |
disabled, not-started, delivered, incomplete, or unavailable |
marolum-operation-id |
Exact accepted operation UUID, when available |
marolum-evidence-id |
Accepted preview evidence UUID for explicit later correlation |
marolum-operation-url |
Exact accepted operation URL, when available |
For PRs, Git is queried in work-directory to establish the tested checkout HEAD; GITHUB_SHA
is not substituted. The source head is read separately from the event. Neither asserts that the
working directory is unmodified. GitHub validates the workflow/run/attempt identity through the
Actions read API. Unavailable identity or denied publishing produces comment-status=failed, a
warning, and the local job summary; it does not change the infrastructure command's result.
Each attempt publishes a running state before previewing, then succeeds or fails its own comment.
A failed preview reports destructive=unknown, never a misleading zero-change result. A canceled
job may leave the running state; follow its exact attempt link for the workflow outcome.
Optional retained evidence
Install the verified CLI matching your image using the website download procedure and its Node.js 26 runtime before the action. No public action tag is claimed. Keep the existing Pulumi login and add these inputs to the preview step:
id: preview
uses: dekglas/marolum-action@<reviewed-commit>
with:
stack-name: production
work-directory: infra
marolum-evidence: 'true'
marolum-command: /opt/marolum/bin/marolum
marolum-url: https://marolum.example.com
marolum-backend-id: states
marolum-project-name: infrastructure
marolum-token: ${{ secrets.MAROLUM_INTEGRATION_TOKEN }}
The CLI owns announcement, heartbeats, finalization, retries and retained evidence. The action asks for bounded resource names, disables transcripts, and supplies the actual checkout revision. It writes a private temporary Pulumi event log and CLI result, removes them after consumption, and never publishes raw logs, properties or tokens. The token must already be scoped to the exact target, either through a restricted scope or an explicitly issued all-stacks evidence-writer scope. The action does not enroll it or change installation/token permissions.
Connected mode follows the CLI's current unqualified --stack contract: use production, with
an explicit backend and a project selected by input, environment or the CLI's existing Pulumi.yaml
detection in work-directory. A qualified Pulumi stack or unsupported executable is refused by
the CLI before execution and falls back to the original standalone preview without altering its
target. not-started reports this separately. A missing CLI or local setup failure also permits a
safe standalone fallback. An absent or invalid result after possible execution never permits
replaying the preview. An interrupted invocation may leave temporary files for runner cleanup.
delivered describes delivery, independently of infrastructure success. incomplete can retain
an accepted operation ID after failed finalization; unavailable means there is no usable result.
The comment also discloses unavailable resource detail or CI provenance. Delivery warnings never
replace the infrastructure exit code. Without the evidence switch no CLI or Marolum service is needed.
A separately authorized later update can pass steps.preview.outputs.marolum-evidence-id to the
existing marolum exec --correlate <evidence-id> -- pulumi up … invocation. Pass it only when that
update intentionally applies this preview: the server validates the explicit relation. The action
does not infer causality from stack names, time, revisions or workflow proximity, and does not run
updates. Use the direct CLI procedure above for separately authorized updates.
Share runner settings across workflows
Store the self-hosted URL and backend ID as repository or organization variables and the token as a secret. Bind them in a trusted job's environment so the action and direct CLI use the same settings. Explicit nonempty action inputs override environment defaults for individual jobs.
env:
MAROLUM_URL: ${{ vars.MAROLUM_URL }}
MAROLUM_BACKEND_ID: ${{ vars.MAROLUM_BACKEND_ID }}
MAROLUM_STACK_LAYOUT: project-scoped
MAROLUM_TOKEN: ${{ secrets.MAROLUM_TOKEN }}
marolum-evidence: 'true' remains an explicit opt-in. For project-scoped evidence delivery,
omit marolum-project-name and MAROLUM_PROJECT to let the installed CLI detect the project
from Pulumi.yaml in the selected working directory. The action does not parse the project
or infer it from a qualified stack reference. Until the CLI reports an accepted operation,
no guessed stack link is shown. Link-only mode still requires the project name. Legacy layout
must omit the project. Use your own domain, such as https://marolum.example.com.
An all-stacks token covers current and future targets but grants only evidence-write authority;
it grants no console read, administration or infrastructure execution. Separate tokens remain
useful for revocation and rate-limit isolation. Rotate by issuing a successor, switching shared
secrets, letting active runs finish with their original token, and revoking the predecessor.
Do not expose runner secrets to untrusted pull requests. Configuration generation or a successful
preview alone does not establish accepted evidence delivery; check evidence-status and the
accepted operation link. CLI installation, connectivity diagnostics and hosted CI acceptance
remain separate from these shared defaults.
Comment contents
The comment carries operation kinds, resource types, and resource names, plus per-kind
counts. It never contains resource inputs, outputs, diffs, or configuration values, but resource names may themselves be sensitive. Authorize their disclosure to the PR audience
before enabling comments.
Rows beyond max-listed-changes are summarized with a count. The action does not print or retain the complete preview document; connected mode removes its
private engine event log after the CLI has reduced it to the closed evidence contract. On events that are not pull requests the summary goes to the workflow step
summary and no comment is posted.
Operational limits
- Requires a runner with Node 20+ (standard GitHub-hosted runners) and the Pulumi CLI on
PATH(orpulumi-commandpointing at one). - A failed preview preserves its process exit code; raw provider stderr is not published.
- A single workflow owns each
comment-tag. Use a distinct tag for every stack and matrix job that represents a distinct target. Tags accept 1–200 letters, digits, underscores, dots, slashes, colons or hyphens. Another workflow or conflicting job identity fails closed. - Run number orders workflow invocations, then attempt orders reruns. Rerunning an older workflow invocation never displaces a newer invocation, even when its attempt number is greater.
- Only exact leading markers owned by
comment-authorare managed. Copied/quoted markers are ignored. Each invocation owns a different comment ID: an old completion cannot overwrite the newer comment. Concurrent creation is reconciled by retaining the newest attempt and retiring older comments. This changes the comment URL across attempts; usecomment-urlfrom that run. - GitHub does not make the head check, creation and reconciliation transactional. Duplicates can briefly exist; denied requests, cancellation, malformed owned metadata or a listing beyond five pages can prevent convergence. Delivery failure is explicit and a later invocation can reconcile. Every summary states its exact source/tested revisions; it never asserts a timeless current head.
- Fork PRs may have neither infrastructure secrets nor comment permission. Do not execute
untrusted PR code with privileged credentials in
pull_request_target. The caller owns code trust, credential exposure and authorization to publish even resource names to the PR audience.
Set up the CLI through the optional action
On a Linux runner with Node.js 26 and npm available, the setup subaction installs the exact
version matching your image; there is no latest fallback. Use an explicitly reviewed full
commit only if you have access to the action repository. Website downloads above are the
ordinary integration path and need no private Dekglas repository access.
- name: Install verified Marolum CLI
uses: dekglas/marolum-action/setup@REPLACE_WITH_REVIEWED_FULL_COMMIT_SHA
with:
version: REPLACE_WITH_SELECTED_IMAGE_VERSION
- name: Report a Pulumi preview
env:
MAROLUM_URL: ${{ vars.MAROLUM_URL }}
MAROLUM_BACKEND_ID: ${{ vars.MAROLUM_BACKEND_ID }}
MAROLUM_TOKEN: ${{ secrets.MAROLUM_TOKEN }}
run: marolum exec -- pulumi preview --stack production
The setup step fetches the versioned website release record and matching CLI archive. It uses the application-owned archive verifier to check version, size, SHA-256, redaction corpus, package manifest and bounded archive paths before installing offline with scripts disabled. A missing, tampered or incompatible artifact stops setup before installation.
Every invocation uses a separate directory under RUNNER_TEMP and adds its executable to the
path for later steps. It does not replace a system installation or use a shared cache. Keep
Node.js 26 available in subsequent steps and retain normal job-temporary-directory cleanup.
Pulumi installation, backend authentication and an evidence token are separate prerequisites.
Setup runs no infrastructure commands. Public action publication, real PR comments,
rerun/concurrency behavior and fresh hosted-runner acceptance are separately qualified.
The HTTP contract
Operation evidence uses announce, heartbeat and finalize routes, with a separate correlation
route. The optional connection check is described below. Authentication is
Authorization: Bearer marolum_it_… on every request; a browser session cannot reach this surface
at all. Content type is application/json, requests are capped at 1 MiB, and a token is limited
to 60 requests per minute per serving process.
Connection check
POST /api/v1/ingestion/check is available only in releases that document the standalone check.
{
"schemaVersion": 1,
"target": { "backendId": "production", "projectName": "platform", "stackName": "prod" }
}
The request has exactly these fields; use projectName: null for legacy layout. A valid token
whose scope covers the supplied target receives
200 {"schemaVersion":1,"status":"authorized"}, with Cache-Control: no-store.
The same token authentication and request/rate limits apply. Invalid credentials receive 401;
out-of-scope targets receive 404. It does not look up stack inventory, admit an operation, or
return read authority. A browser session is not accepted.
Announce
POST /api/v1/ingestion/operations
{
"schemaVersion": 1,
"idempotencyKey": "3f1c2b4a-5d6e-4f70-8a91-b2c3d4e5f607",
"correlationId": "0b6d4f2a-1c3e-4a5b-8d9f-0e1a2b3c4d5e",
"kind": "update",
"startTime": "2026-08-18T12:00:00.000Z",
"target": { "backendId": "production", "projectName": "platform", "stackName": "prod" }
}
kind:destroy,import,preview,refresh,rename,resource-import, orupdate.correlationId: the UUID inside the[marolum:v1:<uuid>]marker you put in the update message, ornullwhen you injected none.projectName:nullfor a legacy-layout backend.- Timestamps are exactly
YYYY-MM-DDTHH:MM:SS.mmmZ. Every field is required; unknown fields are rejected.
Returns 201 {"operationId":"…","status":"accepted","transcriptCapture":"not-permitted"}, or
200 with "status":"replayed" when the same idempotency key and content were already accepted.
transcriptCapture is permitted only for a token an administrator allowed to capture; see
Transcript capture.
Heartbeat
POST /api/v1/ingestion/operations/{operationId}/events
{
"schemaVersion": 1,
"events": [
{
"eventId": "9c8b7a65-4321-4098-b765-43210fedcba9",
"kind": "heartbeat",
"sequence": 1,
"assertedAt": "2026-08-18T12:01:00.000Z"
}
]
}
Send one at least every five minutes: the activity lease is five minutes, and an operation whose
lease expires without a finalize reads incomplete. Up to 100 events per request and 1,000 per
operation. Twenty-four hours after the announce, the operation stops accepting assertions and
returns 409 operation-expired; it keeps reading incomplete.
Finalize
POST /api/v1/ingestion/operations/{operationId}/finalize
{
"schemaVersion": 2,
"result": "failed",
"reason": "command-failed",
"exitCode": 1,
"endTime": "2026-08-18T12:05:00.000Z",
"resourceChanges": [
{ "operation": "create", "count": 1 },
{ "operation": "delete", "count": 2 }
]
}
result:succeeded,failed, orcancelled.reason:command-failedorpolicy-denied, only whenresultisfailed; otherwisenull.policy-deniedrequires explicit policy evidence; an exit code alone never establishes it.exitCode:0–255, ornull.resourceChanges(schema 2):nullfor unavailable,[]for explicitly zero, or rows over the closed action vocabulary (create,update,delete,replace,same,read,refresh,import, and their replacement variants). Schema 1 omits the field and reads as Changes not reported.
Returns 200 {"operationId":"…","outcome":"failed","status":"accepted"}.
Correlate
POST /api/v1/ingestion/correlations
{
"schemaVersion": 1,
"plannedEvidenceId": "…",
"actualEvidenceId": "…",
"externalId": "build-4821"
}
Pairs a preview with an actual operation on the same stack. Both sides must be inside the
token's scope. An assertion has one role in one trusted pair; a reassignment attempt preserves
the original and returns conflict.
Failures
Every failure is one closed reason, {"error":"<reason>"}, and never echoes your payload.
| Status | Reason | Retry? |
|---|---|---|
| 400 | invalid-request, unsupported-schema-version |
No |
| 401 | unauthorized |
No; the token is bad |
| 404 | not-found |
No; out of scope or nonexistent, indistinguishably |
| 409 | conflict, operation-expired, operation-finalized |
No |
| 413 | request-too-large |
No |
| 422 | clock-skew-rejected, event-limit-exceeded, operation-event-limit-exceeded |
No |
| 429 | rate-limited |
Yes, with backoff |
| 503 | unavailable |
Yes, with backoff |
Announce, heartbeat, and finalize are idempotent, so a retry after an uncertain failure is safe. Source timestamps are flagged past ±5 minutes of skew and rejected past ±24 hours.
Writing your own client
If you call the API yourself, the promise is yours to keep. This client announces, runs the
deployment, finalizes and exits with the deployment's own status, whether or not Marolum answers.
It needs curl, jq, MAROLUM_URL and MAROLUM_TOKEN; replace the target and the Pulumi
command with yours:
#!/usr/bin/env bash
set -uo pipefail # deliberately not -e: the deployment owns the exit code
uuid() { tr 'A-Z' 'a-z' </proc/sys/kernel/random/uuid; } # Linux; elsewhere use uuidgen
now() { date -u +%Y-%m-%dT%H:%M:%S.000Z; }
report() { # prints the response on success and can only ever warn on failure
curl --silent --show-error --max-time 5 --fail-with-body \
-H "Authorization: Bearer ${MAROLUM_TOKEN:-}" -H 'Content-Type: application/json' \
"$@" 2>/dev/null || { echo "evidence delivery failed" >&2; return 1; }
}
correlation="$(uuid)"
operation_id="$(report -X POST "${MAROLUM_URL:-}/api/v1/ingestion/operations" --data "$(jq -nc \
--arg key "$(uuid)" --arg correlation "$correlation" --arg start "$(now)" \
'{schemaVersion: 1, idempotencyKey: $key, correlationId: $correlation, kind: "update",
startTime: $start, target: {backendId: "production", projectName: "platform", stackName: "prod"}}')" |
jq -r '.operationId // empty' 2>/dev/null)"
# Deployments longer than five minutes also need heartbeats; see Heartbeat above.
pulumi up --yes --message "deploy [marolum:v1:$correlation]"
status=$?
if [ -n "$operation_id" ]; then
if [ "$status" -eq 0 ]; then result=succeeded reason=null; else result=failed reason='"command-failed"'; fi
report -X POST "$MAROLUM_URL/api/v1/ingestion/operations/$operation_id/finalize" --data \
"{\"schemaVersion\":2,\"result\":\"$result\",\"reason\":$reason,\"exitCode\":$status,\"endTime\":\"$(now)\",\"resourceChanges\":null}" >/dev/null
fi
exit "$status"
The load-bearing details: --max-time on every call, a reporting function that can only warn,
and exit "$status" at the end. A client that lets a failed evidence call abort the script turns
a Marolum outage into a failed deployment, which is the one outcome this integration exists to
prevent.
Transcript capture
The wrapper can also stream the command's stdout and stderr to Marolum as an encrypted, redacted,
bounded transcript. It is off by default and needs three separate opt-ins: a nonzero transcript
retention window on the installation, a token an administrator explicitly permitted to capture,
and --capture=transcript on the invocation. The server additionally needs its external
encryption key and storage headroom.
Capture never changes the command's exit code, keeps no local file, and admits at most 512
chunks, 8 MiB, and four hours per operation. Redaction is applied on the runner and again on the
server, and it cannot find every secret; treat transcripts as sensitive. Reading one requires the
distinct transcript-reader role. After the child exits, transcript delivery gets at most five
seconds within the wrapper's 15-second total finalization ceiling; undelivered output is lost
and reported as a gap. This bound does not change the child's result.
To add local redaction rules, set MAROLUM_TRANSCRIPT_REDACTION_FILE independently on the runner
and server. It must name an absolute, regular, non-symlink file of at most 64 KiB, outside the working directory
on the runner and outside the database directory on the server. The JSON shape is
{"schemaVersion":1,"literals":["a-synthetic-extra-literal"],"patterns":[]}. Use literals for exact
sensitive strings; keep patterns empty for this literal-only configuration.
Invalid rules close capture. Distribute matching rules through trusted configuration when both
processes must apply them; rules are not automatically fetched from the installation. Do not put
real redaction literals in a repository or CI output. Additional rules still do not guarantee a
secret-free transcript.
The installation-side configuration is in Deployment and setup.
What you get
Runner-sourced operations appear in Operations labelled Runner (deployment runner) or
Runner (developer workflow), with in progress, cancelled, and incomplete outcomes that
backend records alone cannot express. For an update with the exact retained Marolum marker,
Marolum records that the runner row and the backend row describe the same run. Both rows stay
listed, each labelled by its source; a runner row that never finalized shows the backend's outcome
marked adopted from correlated record. If the marker is absent or its backend history was
pruned before observation, no join is recorded.
Open the operation link printed by the CLI or find the operation in Operations. Confirm its final outcome and evidence source after the command completes. A short preview may finish before you open the page; an in-progress screenshot is not required to verify delivery.
Limits of this version
- No local spool: undelivered evidence is lost and says so.
policy-deniedrequires a mandatory policy event in a fresh engine log.- Comparisons require an explicit
--correlatepair and retained counts on both sides. - Actor labels, branch names and arbitrary build URLs are never accepted. Commit revisions and GitHub run identity use the closed CI context in announcement schema 4 and its schema 5 extension; the baseline schema 1 example above does not carry that context.
- Install the verified release tarball; there is no registry package.
Optional resource property evidence
A runner can submit bounded, runner-redacted Pulumi property evidence alongside the existing operation lifecycle. It is separately opt-in at the backend and invocation; ordinary operation evidence remains usable without it. Property delivery failures are reported separately and preserve the infrastructure command's result.
The runner removes recognized secrets before sending; the server validates the closed property shape and current masking policy. Unmarked sensitive values can still pass redaction. Neither raw plans nor arbitrary provider output are a property evidence surface. A runner must receive the installation's redaction patterns through its own trusted configuration; a changed policy can refuse an in-flight property submission.
Only explicit valid preview/update correlation enables a comparison. Evidence submission does not grant control approval, execution authority or write credentials. See Resource property evidence for edition, coverage and rollout boundaries. Use the operator and CLI instructions in the documentation preserved with your installation's version.