Mark AI-generated text and media in CI, so what you ship carries provenance a consumer can verify.
The action is a thin client over Encypher's enterprise signing endpoints:
- text:
POST /api/v1/provider/mark, which returns segment-level marked text - media by upload:
POST /api/v1/sign/mediawithai_output=true, which returns a C2PA-signed file - media without upload:
POST /api/v1/sign/media/prepareand/finalize, where the asset is hashed and the manifest spliced on the runner and the payload bytes never leave it
Encypher's commercial layer is segment-level marking of text. C2PA manifests are document-level and asset-level; they do not carry sentence-level provenance, and this action never claims they do.
- uses: encypherai/provider-mark-action@v1
id: mark
with:
api-key: ${{ secrets.ENCYPHER_API_KEY }}
mode: text
input-path: build/release-notes.md
output-path: build/release-notes.marked.md
title: Release notesA fuller workflow covering every mode is in
examples/mark-ai-output.yml.
encypherai/provider-mark-action@v1 is the planned stable reference. That
repository and that tag do not exist yet. The action lives in Encypher's
private monorepo at integrations/github-provider-mark-action; publishing it to
a public repository under the encypherai organization and tagging v1 is a
separate step that has not been done. Until it is, the reference above will not
resolve, and the form that works is the local path:
- uses: ./integrations/github-provider-mark-actionWhat is true today: the artifact is install-ready. It is a Node 20 action with zero runtime dependencies that runs straight from its ref with no build step, so publishing is a repository and tagging exercise, not a packaging one.
Two related notes, so nothing here reads better than it is:
- The prepared-signing client is Encypher's TypeScript SDK, compiled and
committed under
vendor/(see Vendored prepared signing).@encypher/sdkis not published on npm, and this action does not wait for it: nothing here installs from a registry. - Every test in this package runs against a local fixture HTTP server. The
contract is read from the enterprise API source, not from a recorded live
transaction.
npm run live-smokeis the opt-in harness that proves the same paths againsthttps://api.encypher.com, and it has not been run here: no credential exists in this environment. See Live production smoke.
| Input | Required | Default | Notes |
|---|---|---|---|
api-key |
yes | Pass from a secret. Masked before the action writes anything else. | |
api-base-url |
no | https://api.encypher.com |
Must be https, unless the host is loopback for an on-prem sidecar. Usernames and passwords in the URL are refused. |
mode |
yes | text or media. |
|
input-path |
yes | Workspace-relative file to mark. Control characters are refused. | |
output-path |
yes | Workspace-relative file to write the result to. Parent directories are created, and control characters are refused. | |
title |
no | "" |
Single-line label, up to 500 characters. Left empty, the field is omitted and the API default applies. |
enable-watermark |
no | false |
Media mode only, and only for image, audio, or video input. Requires the upload path. Paired with mode: text it fails the step rather than doing nothing. |
local-hash |
no | auto |
auto, always, or never. See Local-hash signing. |
local-hash-threshold-bytes |
no | "" |
Size at or above which auto signs locally. Empty means 98,000,001, one byte past the upload ceiling. |
There is no input for raw content. Content passed inline through with: lands in
workflow run metadata, so the contract is file in, file out.
| Output | Text mode | Upload mode | Local-hash mode |
|---|---|---|---|
document-id |
the marked output id | the signed document id | the signed document id |
verification-url |
tokenized verification URL | verification URL when returned | verification URL when returned |
asset-id |
empty (not in the text contract) | logical asset id | logical asset id |
signed-hash |
empty (not in the text contract) | hash of the signed bytes when returned | empty, always |
c2pa-signed |
marking_layers.provenance |
c2pa_signed |
true |
watermark-applied |
marking_layers.watermark |
watermark_applied |
empty, always |
output-path |
workspace-relative path written | same | same |
signing-mode |
text |
byte |
prepared |
Every output is always published. An empty value means the API did not report
that field, never false.
Mappings worth knowing:
- In text mode the API reports no
c2pa_signedfield.c2pa-signedtherefore carriesmarking_layers.provenance, the API's "a signed provenance record was produced" flag. Read it as that, not as a claim about a specific manifest. - If the API omits
watermark_applied,watermark-appliedis empty and the log says so. The action does not guess. - Local-hash mode reports no
signed-hash. The protocol returns a binding commitment over the hashed byte ranges, which is a different number from a hash of the finished file; publishing it under the same output name would be a lie by naming. It reports nowatermark-appliedeither, because the invisible watermark is embedded server-side from payload bytes that never left. c2pa-signedistruein local-hash mode by construction, not by the server's say-so: the client verifies the returned manifest store's SHA-256 and its JUMBF structure before splicing it in, and refuses the file otherwise.signing-modeexists so a workflow can assert where its bytes went. Nothing else in the output set distinguishes an upload from a local splice.
The marked text and the signed bytes are never outputs and never logged. They
exist only at output-path.
Also called prepared signing. For supported media the action parses the container locally, sends only the offset-bearing structural bytes and a digest, receives signed-carrier geometry plus a manifest store, and splices the manifest into the file itself. The payload never crosses the network.
Two reasons to use it:
- Size. The upload endpoint caps the whole request at 100,000,000 bytes. A larger asset can only be signed this way.
- Egress. For assets whose contents must not leave the runner, this is the only path that does not send them.
local-hash controls it:
| Value | Behaviour |
|---|---|
auto (default) |
Local-hash at or above local-hash-threshold-bytes, which defaults to 98,000,001: one byte past the 98,000,000-byte upload ceiling. A file exactly at the ceiling still uploads, because the upload path can still take it. So auto switches on the first size the upload path cannot carry, and never sooner. Raising the threshold above the ceiling does not move that switch: an asset the upload endpoint cannot carry is local-hashed whatever the threshold says. |
always |
Local-hash, or fail. Never a silent upload. |
never |
Upload, or fail if the asset is too large to upload. |
The rule underneath all three: the upload path is chosen only when the upload path can actually do the job. An asset over the ceiling whose format the prepared client cannot parse is a hard failure naming both reasons, not a doomed 413. An entitlement failure mid-request is a hard failure too, never a fallback that uploads the bytes you asked to keep local.
Lower the threshold to prefer local hashing for smaller assets:
with:
local-hash: 'auto'
local-hash-threshold-bytes: '8388608' # 8 MiBIt is entitlement gated. Local-hash signing is an Enterprise feature, enabled
per organization. Before hashing anything the action calls
GET /api/v1/sign/media/hash/capabilities, which the API documents as the
authoritative answer for both availability and the accepted format list. If it
reports enabled: false, the step fails and says so, including what to ask for.
If it publishes a format list that excludes your file's MIME type, the step fails
and says that instead.
Supported formats are whatever that endpoint publishes. The client can parse images (JPEG, PNG, GIF, WebP, AVIF, HEIC/HEIF, JXL, TIFF/DNG, SVG), audio (WAV, MP3, Ogg, FLAC, M4A), and video (MP4, MOV, M4V, AVI). PDF is not among them: sign documents by upload.
No watermark. enable-watermark with local-hash: always is a hard error
rather than a silent drop of one or the other.
MIME classification does not read the file. The action classifies from a 65,548-byte head, which is the widest window any of its sniffers inspects, so naming a 5 GB asset's type costs one small read.
Each run marks again. Every invocation sends a fresh Idempotency-Key, which
every endpoint here requires. Re-running a job produces a new marked asset, a new
document-id, and a new metered signing operation. That is deliberate: the
alternative is a key derived from content, which would silently return a stale
marking for edited content.
The signed file is the deliverable. In media mode, a later step that
re-encodes, re-exports, or optimises the image strips the credential. Upload or
publish the bytes at output-path.
Watermarks are media-specific. The API takes one of
enable_image_watermark, enable_audio_watermark, or enable_video_watermark
and rejects a mismatch. The action classifies the file from its bytes and sends
exactly the right one. If the file cannot be classified as image, audio, or
video, a watermark request fails before anything is uploaded rather than
uploading a request the API would refuse.
Paths stay in the workspace. input-path and output-path resolve against
GITHUB_WORKSPACE and must stay inside it after symlinks are followed. The input
is opened once at admission. That one held descriptor supplies MIME sniffing,
upload bytes, prepared parsing, hashing, prepare/finalize binding, and assembly;
no later phase reopens the mutable workspace path. The SDK still rechecks that
the admitted name points to the held inode before either metered prepared request
and before publication, so a swapped file or parent fails closed.
Outputs are proven before the deliverable appears. A hostile or careless response cannot leave a signed file on disk while output publication fails.
Local-hash mode has to work for this differently, because the SDK assembles and
renames the file itself: the action hands it a path and only regains control once
the file exists. A temp file beside output-path would sit in an
attacker-reachable directory for that whole window. So the action creates a
private staging directory instead, mode 0700, with an unguessable name, under the
resolved workspace root, and gives the SDK a path inside it. The assembled file
is renamed onto output-path only after every output value has passed
validation, so a failed run leaves any pre-existing file at output-path
untouched and no residue anywhere.
Output replacement keeps the pre-existing target's permission bits without
widening them. A new output is created as mode 0600, independent of runner
umask. The prepared SDK receives that admitted target mode for its private staged
file, so the final descriptor-anchored rename preserves the same rule.
Both the staging directory and the staged file are pinned by HOLDING an open
descriptor, not by remembering their dev/ino. Remembering the numbers does
not work: a filesystem may hand the same inode straight back for a path deleted
and recreated, and ext4 does, so the check would pass on a swap. Holding a
descriptor keeps the verified inode allocated, which forces any replacement to
differ. Immediately before the rename the action re-proves the staging directory,
the staged file, the output parent directory, and the output component.
Those checks validate paths, and rename re-resolves paths, so the move itself
is made through descriptor-anchored paths on both ends
(/proc/self/fd/<dirfd>/<name>), which the kernel resolves through a directory
inode the process holds open instead of walking the path again. A parent swapped
after the checks cannot redirect either end. Immediately after the rename the
published entry is compared against the held staged descriptor.
On a mismatch the foreign entry is cleared through the anchored destination, by
type. A file or symlink is unlinked, which for a symlink removes the link and
never its target. A directory cannot be unlinked at all, so it is renamed into
the private staging directory instead of being recursively deleted at the path
the attacker chose; the staging area destroys it later, behind its own identity
check. Either way output-path is then proven absent with an ENOENT check
before the step fails.
Stated as narrowly as it is true. On success: output-path holds the exact inode
that was verified, and the move could not have landed outside the pinned output
directory. On mismatch: the foreign entry is cleared, its absence is observed,
and nothing is reported; if it could not be cleared, the step still fails but
says that instead of claiming a clean state it never saw. It is not a claim that
no other inode was ever transiently visible at that path, because Node exposes no
renameat and that claim would not be honest.
Local-hash signing needs Linux. The anchoring above is implemented with
/proc/self/fd, and the action probes it functionally rather than checking a
platform name, so a Linux container without procfs fails the probe too. Where it
is unavailable, prepared signing refuses to run before any request is sent and
tells you to use local-hash: never. Text and byte modes are unaffected on every
platform: they write through a path the action never hands to anyone else.
Limits are checked locally first. Text is rejected before the request if it is empty, over 1,000,000 characters, or carries control characters the endpoint refuses. Titles in both modes are rejected over 500 characters or if they carry control characters. Uploaded media is rejected over 98,000,000 bytes, which keeps the multipart request under the API's 100,000,000-byte ceiling. A local-hash structure over the API's 48 MiB request ceiling is rejected before the socket opens. Requests time out after 120 seconds and responses are read under a byte cap.
Errors say what happened, not what you sent. A failure reports the HTTP
status and the API's bounded error code and message. Credentials, request
headers, marked text, the base64 carrier, and container structure never reach a
log line. Credential detection covers raw and Bearer text, bounded iterative
percent/form decoding (including %25XX nesting), UTF-16LE and UTF-16BE byte
forms, and standard/base64url candidates at any phase inside a larger encoding.
Every server-derived product is checked before publication, including
signed_text, decoded signed media, prepared plan patches and finalized carrier
material, workflow outputs, files, and log fields. Prepared mode also scans the
complete assembled artifact through its pinned staged descriptor in bounded
overlapping windows, which catches a credential preserved from source bytes
before commitStaged. A product match rejects the response. A log form that
cannot be safely replaced in place withholds the whole message. Signed content
is never changed by redaction.
with:
api-base-url: https://encypher.internal.example.comhttp is accepted only for localhost, 127.0.0.1, and [::1]. Userinfo in
the URL is refused. Logs display only the validated canonical URL, never the raw
input. For https, the action refuses to run when
NODE_TLS_REJECT_UNAUTHORIZED=0 because that disables certificate verification
for the bearer-token request.
Prepared signing is intricate: container parsing for a dozen formats, a hash
program, JUMBF validation, anti-swap guards around every read. That code exists
once, in Encypher's TypeScript SDK (sdk/typescript/src/preparedSigning.ts),
along with the generated serializers that own the snake_case wire mapping.
Rewriting either one here would be a second implementation of a
security-critical parser.
The action cannot depend on it the usual way: @encypher/sdk is not published on
npm, and a GitHub Action runs from its ref with no install step. So the SDK's
compiled output is vendored:
npm run vendor # regenerate vendor/ from the SDK sourcesvendor/ holds the transpiled CommonJS closure of preparedSigning.ts plus the
prepared-signing model serializers. It compiles file-for-file with no bundler,
because every value import in that closure is either a Node builtin or another
file in the closure; the model imports in preparedSigning.ts are type-only and
erase to nothing.
vendor/MANIFEST.json pins, per file, the SHA-256 of the TypeScript source it
came from and of the JavaScript that was emitted. npm run vendor regenerates
both the complete transitive CommonJS closure and the manifest from SDK sources;
generated files are never edited by hand. npm run build checks both directions,
so a hand-edited vendored file, a missing closure member, or a tree that drifted
from the SDK fails instead of shipping stale parser code. Inside the monorepo the
source half of that check runs; in the action's own repository, where the SDK
tree is absent, the build reports that half as skipped rather than passing it
silently.
What this package owns is the seam, not the parser: when local hashing applies
(src/prepared.js), the entitlement probe, credential-free product gate, and
the SDK transport interface over the same hardened HTTP client as upload mode.
The seam passes the already-open FileHandle and admitted output mode into the
SDK, so vendoring does not create a second parser or a mutable-path reopen.
scripts/live-smoke.js invokes the packaged entrypoint as a child process, the
way the runner does, against https://api.encypher.com by default. It runs three
legs: text marking, media by upload, and media by local hash. Each checks the
emitted file and outputs. The byte and prepared legs then submit their completed
artifacts independently, without authentication, to
POST /api/v1/public/verify/media.
ENCYPHER_LIVE_SMOKE=1 ENCYPHER_API_KEY=... npm run live-smoke| Variable | Default | Meaning |
|---|---|---|
ENCYPHER_LIVE_SMOKE |
unset | Must be 1. Unset, the harness prints why it skipped and exits 0. |
ENCYPHER_API_KEY |
unset | Required when opted in. Absent, the harness exits 1 without sending anything. |
ENCYPHER_API_BASE_URL |
https://api.encypher.com |
Override for a manually run harness with a separately supplied key. The hosted production workflow does not expose this override. |
It never silently passes. No opt-in is a loud skip; no credential is an error with nothing sent; no hash-mode entitlement fails the prepared leg with the entitlement named, rather than reporting two green legs and a missing third.
There is deliberately no capabilities probe in the harness process. It would be a
second transport for the credential, outside the action's own origin and TLS
guards, to learn something the packaged prepared leg already reports: the action
probes capabilities itself and fails with the entitlement named. One request path
for the bearer token, and the diagnosis comes from the code a user actually runs.
What the harness does check up front are two pure functions imported from the
action, resolveApiBase and assertTlsVerificationEnabled, which send nothing:
a bad origin or NODE_TLS_REJECT_UNAUTHORIZED=0 is one line instead of three
identical leg failures, and no future edit can reintroduce a parent request under
a disabled certificate check.
Public media verification uses regular verification, not strict conformance. A
media leg passes only with success=true, valid=true, tampered=false,
c2pa_manifest_valid=true, reason_code=OK, and integrity=valid. A top-level
checks array is required. A bounded iterative walk finds every field named
checks or ending in checks, case-insensitively, including ingredient
collections. Depth, node, and check-record budgets fail closed. Every record
must be an object with boolean critical and boolean ok; each critical record
must carry ok=true.
Trust must be explicit and consistent. The verifier collects every trust source
present: top-level trust, reader_verdict, and signer/anchor material. If
either signer or trust_anchor_name is present, that source counts and passes
only with a readable signer object, trust_level=ca_verified, and a nonblank
anchor. At least one source must report trusted and every source present must
agree. One trusted source cannot override an untrusted or malformed source.
Integrity alone never becomes a trusted verdict.
The key travels only in the child's environment, and every line the child writes
passes through the action's own redactor before it reaches the harness log,
including the ::add-mask:: line that necessarily carries the secret.
Each run performs exactly three real, metered signing operations. That is why it
is opt-in and why the fixtures are a 69-byte PNG and one line of text.
.github/workflows/live-smoke.yml is workflow_dispatch only. It accepts no API
origin input: the production-smoke environment's secret can be sent only to the
fixed https://api.encypher.com origin. Its three uses: ./ signing legs all
continue far enough to diagnose the others. Completed byte and prepared
artifacts undergo the same independent public verification, then one final
aggregate step fails the job once if any leg or verification failed. The old
harness-summary workflow step was removed because it repeated all three metered
signings. The workflow is written for the action's own repository layout and is
inert in the monorepo, where GitHub reads workflows only from the repository
root.
Not yet observed. No live credential exists in this development environment,
so the harness is proven only against the loopback fixture, by
tests/live-smoke.test.js, which drives the real script as a child process and
covers the opt-in gate, the missing-credential path, the unentitled path, a
failing leg, redaction, the zero-request origin and TLS refusals, and all three
legs passing. A real 201 from provider/mark and a real 200 from
sign/media and from prepare/finalize at api.encypher.com remain
unobserved.
The text leg cannot pass in production today. Probed directly at
https://api.encypher.com: POST /api/v1/sign/media,
POST /api/v1/sign/media/prepare, and POST /api/v1/sign/media/finalize all
answer 401 with Encypher's JSON error envelope, so they are routed and
auth-gated as expected. POST /api/v1/provider/mark answers 404 with
text/plain 404 page not found, which is a router miss rather than an API
response: the deployed edge does not route the text marking endpoint at all. The
action handles that correctly, reporting HTTP 404 (non-JSON response body, not echoed), but no credential can make the text leg green until that route is
deployed. Media and local-hash signing are unaffected.
The action has no runtime dependencies. Node 20 provides fetch, FormData,
Blob, and crypto.randomUUID, and the Actions runtime contract it needs
(inputs, masking, GITHUB_OUTPUT) is a handful of documented conventions
implemented in src/core.js. There is no bundler and no dist/: GitHub runs the
committed tree as-is. The one generated part of the tree is vendor/, which is
hash-pinned rather than trusted.
npm test # unit, contract, and end-to-end smoke tests, all offline
npm run build # verify the artifact is runnable from the ref
npm run vendor # regenerate the vendored prepared-signing modules
npm run live-smoke # opt-in production smoke, see abovenpm run build generates nothing. It checks that action.yml declares node20
with a main that exists, that action.yml's inputs and outputs are exactly
what src/index.js reads and sets, that no runtime dependency has crept in, that
every import is a Node builtin or a local module, that every runtime file parses,
that the vendored tree matches its manifest in both directions, and that no
runtime file is gitignored.
npm run vendor needs the TypeScript compiler, which is not a dependency of this
package because the action ships the emitted JavaScript. Install it transiently
(npm install --no-save typescript) or point ENCYPHER_TYPESCRIPT_DIR at an
existing install.
The smoke tests run src/index.js as a child process against a local fixture
HTTP server, in every mode, and assert the output bytes, the published outputs,
and that the key appears nowhere after the mask. For local-hash mode the fixture
builds the same PNG caBX carrier and JUMBF manifest store the SDK's own
prepared-signing suite builds, because the client validates the plan geometry,
the manifest structure, both digests, and the assembled file's binding: a stub
would be rejected before anything was written. The HTTP boundary is the only
thing tests replace.