Skip to content
Dekglas
Marolum documentation

Runner integration

Report deployments to Marolum from CI or a developer machine with marolum exec or the ingestion API.

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.

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.

  1. 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.

  2. 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.com is a placeholder:

    pulumi config set marolum:stackUrl 'https://marolum.example.com/#inventory/production/project-scoped/payments/prod' --stack prod
    
  3. Save the following as marolum-stack-link.ts beside your program's entry file, then import it from the entry file with import './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.
  }
}
  1. Run pulumi preview --stack prod. Output includes View 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_TOKEN and MAROLUM_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. https is required; http is admitted only for loopback. Certificate verification is never relaxed; the wrapper refuses to run when NODE_TLS_REJECT_UNAUTHORIZED=0 is set. Supply a private CA with NODE_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 exits 64. 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

  1. Have an administrator issue a replacement with the required target scope and expiry.
  2. 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.
  3. Let already-running commands finish using their original token.
  4. 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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 that pulumi stack history shows 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.
  6. 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 (or pulumi-command pointing 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-author are 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; use comment-url from 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, or update.
  • correlationId: the UUID inside the [marolum:v1:<uuid>] marker you put in the update message, or null when you injected none.
  • projectName: null for 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, or cancelled.
  • reason: command-failed or policy-denied, only when result is failed; otherwise null. policy-denied requires explicit policy evidence; an exit code alone never establishes it.
  • exitCode: 0–255, or null.
  • resourceChanges (schema 2): null for 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.

Completed synthetic preview with Succeeded outcome, Runner (deployment runner) source and Create 2 resource changes.
Marolum 0.21.1 Community, Marolum Light: a completed preview from a real local Pulumi component program, using synthetic data. Runner (deployment runner) identifies the evidence source; Create 2 is the reported resource-change count. This capture does not show a cloud deployment. Open the image for full-size labels.

Limits of this version

  • No local spool: undelivered evidence is lost and says so.
  • policy-denied requires a mandatory policy event in a fresh engine log.
  • Comparisons require an explicit --correlate pair 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.