Uploading and publishing content from CI
Pipelines use the same authenticated CMS API as the editor. Get an OAuth2
access token from the identity provider with the client credentials grant,
then send Authorization: Bearer <access_token> on every CMS request. Viking
validates the token; the identity provider issues it. No interactive login,
session cookie or CSRF bootstrap is needed for verified Bearer requests.
Browser sessions still require CSRF protection.
Register a dedicated confidential service client at the provider with client credentials enabled. Configure its access tokens to include:
- The CMS provider's issuer and a valid expiry.
- The CMS API audience: the CMS authentication Secret's
audiencevalue, falling back to itsclientID. The pipeline's own client ID can differ. - A role accepted by
allowedRoles, if configured, and bypublishRolesif the pipeline should publish. A writer without a publish role can edit drafts but receives403when publishing.
Tokens must be signed JWTs verifiable through the configured JWKS, or opaque
tokens validated by the configured introspectionURL. See
authentication. Use the
provider's token endpoint and audience/scope settings; these differ by provider.
The CMS does not register the pipeline client or grant it roles automatically.
Roles grant CMS-wide access, not access restricted to one URI branch.
Prepare the job inputs
Run these examples in Bash, with curl supporting --fail-with-body, jq,
trusted HTTPS CA certificates and network access from the runner to both IdP
and CMS. Shell variables persist only within the same shell/job context.
Configure these masked/protected CI settings as appropriate to your runner:
| Variable | Value to supply |
|---|---|
CMS_URL |
External CMS origin with scheme and no trailing slash, such as https://cms.example.net; not the public site origin |
OAUTH_TOKEN_URL |
Actual IdP token endpoint URL from provider configuration/discovery, not merely its issuer URL |
CI_CLIENT_ID |
Dedicated confidential service client's registered ID |
CI_CLIENT_SECRET |
That service client's secret; never a committed example value |
OAUTH_SCOPE |
Optional provider-specific scopes for the requested access token |
The audience is configured at the IdP (or through its documented token-request
parameters); this generic request does not assume a provider-specific audience
parameter. ACCESS_TOKEN is obtained below and ASSET/ASSET_URL are set by
the upload response later. Do not print these credentials or enable shell tracing.
Example docs/start.md, docs/example.html and docs/diagram.png are input files
from your content repository/job working directory, not fixtures shipped by
Viking's docs. Create those files or replace the local paths with real inputs.
The public URI /docs/start is independent of the local filename.
Store the service client's secret in masked CI variables. Use HTTPS. Obtain a token:
set -euo pipefail
# Configure CMS_URL, OAUTH_TOKEN_URL, CI_CLIENT_ID and CI_CLIENT_SECRET as CI variables.
# OAUTH_SCOPE is optional and provider-specific. Configure the API audience at the IdP.
ACCESS_TOKEN=$(curl --fail-with-body --silent --show-error \
--user "$CI_CLIENT_ID:$CI_CLIENT_SECRET" \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode "scope=${OAUTH_SCOPE:-}" \
"$OAUTH_TOKEN_URL" | jq -er '.access_token')
Request a fresh token for each job, or obtain another if it expires during a long
job. A rejected/missing token returns 401; missing roles return 403.
If an Origin or Referer header is sent, the existing same-origin check applies.
Run the folder scripts
The repository includes three independent Bash scripts. Install curl with
--fail-with-body support; uploading also needs Bash 4+, jq and find with
-print0. Export these variables so the scripts inherit them (access_token
is lowercase):
export access_token="$ACCESS_TOKEN"
export VIKING_URL="$CMS_URL"
export SOURCE_FILES='/path/to/content'
export PUBLISH_POINT='/docs'
# Run from the Viking repository root; stop if any step fails.
scripts/delete-pages.sh &&
scripts/upload-files.sh &&
scripts/publish-site.sh
VIKING_URL is the CMS base URL. PUBLISH_POINT is a URI path, not a full URL.
Deletion includes the branch root; explicitly setting / deletes every draft
page. It preserves uploaded media and existing releases. Deletion needs no
source directory; publishing needs only the token and CMS URL.
Upload scans the source directory recursively for lowercase .md, .jpg and
.png extensions. SVG files are reported and skipped; all other unsupported
files and symbolic links are ignored. Relative directories and filename stems
become page URIs: guide/start.md becomes /docs/guide/start and
images/photo.jpg becomes /docs/images/photo. README.md becomes
/docs/README; index.md becomes /docs/index. Neither is renamed or mapped
to the branch root. Titles use the filename stem. Directory names and stems
must satisfy Viking's URI segment rules; invalid paths and collisions such as
photo.md plus photo.png fail before any upload.
Markdown uploads replace pages by URI and preserve source bytes. JPG/PNG files
are uploaded as assets and receive HTML pages displaying them below the publish
point, hidden from the menu. Their image bytes use generated
/api/assets/<id>/content URLs. Existing pages at these URIs are replaced.
Relative page links and image references in Markdown are not rewritten;
use the destination page URIs and returned image URLs in your content.
Repeating an image upload creates a new asset; previous media remains stored.
All scripts stop on HTTP errors. Draft changes made before a failed upload
remain saved. The publish script activates the entire site, including draft
edits outside PUBLISH_POINT. Serialize jobs that edit or publish the same site.
Upload a page by URI
PUT /api/pages/content?path=/docs/start&title=Getting%20started&menu=false
accepts a raw UTF-8 body with Content-Type: text/markdown or text/html
(optionally charset=utf-8), up to 8 MiB. path and title are required;
menu is optional and accepts only true or false. Unknown or duplicate
query parameters are rejected. URI paths follow the existing page rules: each
segment starts with an ASCII letter or digit and uses ASCII letters, digits,
dots, underscores or hyphens; trailing slashes are normalized. Extensions such
as /docs/overview.md are allowed. See page URL rules
for length limits and reserved paths.
New pages return 201 and replacements return 200, with the full page JSON.
Uploading again at the same URI preserves its page ID and can explicitly switch
between HTML and Markdown. Menu and layout settings are preserved when omitted;
new pages appear in the menu by default. An explicit menu value supplies a
root-level menu entry at position zero. Markdown's leading !! menu,
!! menu-position and !! menu-parent directives can supply more detailed
menu metadata and take precedence over the query's menu setting. For full
HTML menu metadata or CSS, use the existing JSON page API instead.
curl --fail-with-body --silent --show-error \
-X PUT -H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: text/markdown; charset=utf-8' \
--data-binary @docs/start.md \
"$CMS_URL/api/pages/content?path=/docs/start&title=Getting%20started&menu=false"
curl --fail-with-body --silent --show-error \
-X PUT -H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: text/html; charset=utf-8' \
--data-binary @docs/example.html \
"$CMS_URL/api/pages/content?path=/docs/example&title=Example&menu=false"
HTML is stored as authoring content and sanitized for preview/publication. Scripts and unsafe markup are removed. Markdown remains source, with raw HTML disabled. These page uploads use a separate route from binary assets.
Upload images
Use the existing multipart POST /api/assets endpoint with field file:
ASSET=$(curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-F 'file=@docs/diagram.png' "$CMS_URL/api/assets")
ASSET_URL=$(jq -er '"/api/assets/" + .id + "/content"' <<<"$ASSET")
Use ASSET_URL in Markdown () or an HTML
image's src. The same URL serves the image on the published site. Raster image
formats follow the asset allowlist; SVG
and executable assets remain unsupported. Asset uploads create new IDs, so keep
the returned ID if you want to reuse an upload. Relative image references in
Markdown/HTML are not automatically imported or rewritten.
For arbitrary binaries and files, use POST /api/assets?raw=true and associate
the returned ID with a format: "raw" page. See
raw objects and plugins API for the JSON fields, related
images, repeated block templates and portable package API.
Remove a URI branch
DELETE /api/pages/branch?path=/docs deletes the /docs draft and every draft
whose URI starts with /docs/. It preserves /docs-old and pages whose menu
parent is /docs but whose URI is elsewhere. The branch root need not exist.
It returns 200 {"count": 3, "deleted": [...]} with deleted page summaries.
Repeating a deletion returns an empty list. A missing/empty path is rejected;
an explicit path=/ removes all draft pages.
curl --fail-with-body --silent --show-error \
-X DELETE -H "Authorization: Bearer $ACCESS_TOKEN" \
"$CMS_URL/api/pages/branch?path=/docs"
Deletion is journaled and completes before the next page read, mutation or publication snapshot, including after restart. It preserves media and existing published releases. Uploading a group of pages is a sequence of draft edits, not an atomic batch: serialize jobs that modify/publish the same site. An upload failure leaves successful draft edits in place, and the public site continues serving its previous release until an explicit publish.
Publish
Once all uploads succeed, publish the complete current draft project:
curl --fail-with-body --silent --show-error \
-X POST -H "Authorization: Bearer $ACCESS_TOKEN" "$CMS_URL/api/publish"
This is synchronous: 200 returns the release ID after activation. A publish
includes every draft page, settings and assets, including edits outside the
pipeline's URI branch. GET /api/publish/status reports the current release.
Menu visibility affects navigation only; hidden pages still publish and appear
in URI-based Index blocks. Repeated publish requests create separate releases.
Add an Index block
In the visual page editor, add Site → Index. The editor displays a placeholder; Preview shows the current generated list. In uploaded HTML, use:
<ul class="vc-index" data-viking-index=""></ul>
In Markdown, put {{index}} in a paragraph of its own, with blank lines around
it. Occurrences in prose, inline code and fenced code remain literal.
An Index lists only direct URI children, regardless of their menu metadata.
For /docs, /docs/start is included and /docs/start/details is excluded.
Items are sorted alphabetically by title, ignoring case, with URI as a stable
tiebreaker. Each li contains a linked title followed by a p containing the
first nonempty paragraph's plain text. Headings, shared headers/navigation/
footers and other Index blocks do not supply summaries. Pages without a
paragraph have only their title link; an empty title falls back to the URI.
An Index with no children renders an empty ul.
Index content is generated from the same project snapshot as the release and also resolves in unsaved previews. It never changes authoring source or expands recursively. After uploading or deleting children, publish again to update the public list.
