Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e56262d
Carry the extension build contract's release checks: per-SDK tags at …
amaechiabuah Sep 28, 2026
8a8c515
Add extension-sign: sign and verify extension.zip.sig against the con…
amaechiabuah Oct 6, 2026
dd244ea
Fix extension-sign verify: parse the console's real root-keys shape a…
amaechiabuah Oct 6, 2026
87a597e
Add the extension-sign composite action
amaechiabuah Oct 6, 2026
a364f6b
Add the extension publisher allowlist
amaechiabuah Oct 6, 2026
2aaa8c9
Make publisher_extension_uuid exit exactly 1 on every no-match
amaechiabuah Oct 6, 2026
dc841cf
Sign Duplo releases and attach the signature, with a resume path for …
amaechiabuah Oct 6, 2026
0f06505
Fail one extension instead of the whole run when its release-view loo…
amaechiabuah Oct 6, 2026
75184df
Upload and register each signed build from its Release
amaechiabuah Oct 6, 2026
c29c84b
Verify the release digest, check console lookups, and close publish_b…
amaechiabuah Oct 6, 2026
c4e3691
Release workflow: OIDC role, signer install, one run per repository, …
amaechiabuah Oct 6, 2026
345d088
PR checks warn about every refusal the release job would make
amaechiabuah Oct 6, 2026
c5e5f07
Release job refuses a resourceless or oversized bundle, and PR checks…
amaechiabuah Oct 6, 2026
80404f3
Document the extension release action: signing, upload and registration
amaechiabuah Oct 6, 2026
536e2cd
Fix four accuracy issues in the release action doc
amaechiabuah Oct 6, 2026
ef841f9
Short-circuit a finished build at the console lookup, guard the artif…
amaechiabuah Oct 6, 2026
53e8636
Run the release job only on main and keep the signer tests from writi…
amaechiabuah Oct 6, 2026
20fb4ae
Read BUILDER_TAG=latest as unset so older .env files keep the pinned …
amaechiabuah Oct 7, 2026
01d0959
Warn about an unbumped version only when it already has a release tag
amaechiabuah Oct 7, 2026
7eaa7ce
Refuse to sign with a certificate that is not valid yet
amaechiabuah Oct 7, 2026
f0de420
Document the one-time console setup a release depends on
amaechiabuah Oct 7, 2026
8d43bb9
Pin every workflow action by commit SHA
amaechiabuah Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,15 @@ QDRANT_API_KEY=devkit-local-qdrant
#RUNTIME=docker

# Extension build toolchain (.NET SDK 8 + Node 22). Only pulled the first time you build an
# extension — the platform itself doesn't use it. An already-pulled tag is reused without checking the
# registry: BUILDER_PULL=1 forces a re-pull (this tag moves), BUILDER_IMAGE=<ref> replaces the image
# outright, BUILDER_PLATFORM=<os/arch> forces an architecture. Each build's package caches live in
# extension — the platform itself doesn't use it. The default is pinned by digest in scripts/_builder.sh,
# so every build of a version uses one toolchain. BUILDER_TAG=<tag> opts out of the pin for any tag but
# latest, which reads as unset; an already-pulled tag is reused without checking the registry, and
# BUILDER_PULL=1 forces a re-pull (tags move). BUILDER_IMAGE=<ref> replaces the image outright, so
# BUILDER_IMAGE=quay.io/duplocloud/duplo-extension-builder:latest builds on the moving tag.
# BUILDER_PLATFORM=<os/arch> forces an architecture. Each build's package caches live in
# per-uid named volumes that nothing prunes — `<runtime> volume ls | grep duplo_devkit` to find and
# `<runtime> volume rm` to reclaim them (each holds a full NuGet + npm closure).
BUILDER_TAG=latest
# BUILDER_TAG=1.2.3

# Build on THIS machine instead of in the toolchain container. Set to 1 if you already have .NET SDK 8
# and Node 22+ installed and would rather use them — you own keeping them current, and your builds stop
Expand Down
35 changes: 35 additions & 0 deletions .github/actions/extension-sign/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Signs one built extension.zip into extension.zip.sig with a publisher key the DuploCloud console certifies.
# For pipelines outside devkit's release job, which calls scripts/extension-sign.py directly.
name: extension-sign
description: Sign an extension bundle for DuploCloud portals that require signed extensions
inputs:
bundle:
description: Path to the built extension.zip
required: true
signing-key:
description: Publisher private key PEM, from a secret
required: true
signing-cert:
description: Publisher certificate (compact JWS) from the console
required: true
outputs:
signature-path:
description: Path of the written .sig
value: ${{ steps.sign.outputs.path }}
runs:
using: composite
steps:
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.12'
- shell: bash
run: pip install --require-hashes -r "$GITHUB_ACTION_PATH/../../../scripts/extension-sign.requirements.txt"
- id: sign
shell: bash
env:
EXTENSION_SIGNING_KEY: ${{ inputs.signing-key }}
EXTENSION_SIGNING_CERT: ${{ inputs.signing-cert }}
BUNDLE: ${{ inputs.bundle }}
run: |
python3 "$GITHUB_ACTION_PATH/../../../scripts/extension-sign.py" sign "$BUNDLE"
echo "path=$BUNDLE.sig" >> "$GITHUB_OUTPUT"
4 changes: 4 additions & 0 deletions .github/extension-publishers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"schemaVersion": 1,
"publishers": []
}
24 changes: 19 additions & 5 deletions .github/workflows/builder-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,20 @@ jobs:
# re-dispatch the workflow.
if: github.repository == 'duplocloud/devkit'
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
# QEMU emulates arm64 for the cross-build; buildx provides the multi-platform driver.
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
# Keep this step and its `with:` block adjacent. A dependabot merge once reordered these two `uses:`
# lines and left the credentials attached to setup-buildx-action, which silently ignores them —
# login-action then ran with no inputs and every publish died on "Username and password required".
- uses: docker/login-action@v4
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_PASSWORD }}
- uses: docker/build-push-action@v7
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
id: push
with:
# `build`, not `.`, to match how scripts/_builder.sh builds this same image locally: the
# Dockerfile COPYs nothing from the build context (only --from=node), so sending the whole
Expand All @@ -61,3 +62,16 @@ jobs:
tags: |
quay.io/duplocloud/duplo-extension-builder:latest
quay.io/duplocloud/duplo-extension-builder:${{ github.sha }}
# scripts/_builder.sh pins the default toolchain by digest, so a publish here changes nothing until that pin
# moves in a reviewed PR. Hand the reviewer the exact value.
- name: Report the digest to pin
env:
DIGEST: ${{ steps.push.outputs.digest }}
run: |
{
echo "### Builder image published"
echo "To build with it by default, set in scripts/_builder.sh and docker-compose.yml:"
echo '```'
echo "quay.io/duplocloud/duplo-extension-builder@${DIGEST}"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
15 changes: 12 additions & 3 deletions .github/workflows/extension-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ jobs:
# in an `if:` expression, but `env` is. A fork PR gets no org secrets, so this is empty there.
QUAY_USERNAME: ${{ secrets.QUAY_USERNAME }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# The PR's base history, so the release checks below can diff each extension against the merge base.
fetch-depth: 0

- name: Discover extensions
id: guard
Expand All @@ -55,7 +58,7 @@ jobs:
# image build. See the env: block above for why this reads env rather than secrets.
- name: Log in to quay.io (for the builder image)
if: steps.guard.outputs.build == 'true' && env.QUAY_USERNAME != ''
uses: docker/login-action@v4
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
Expand All @@ -65,9 +68,15 @@ jobs:
if: steps.guard.outputs.build == 'true'
run: ./scripts/build-all.sh

# Warnings only: what each changed extension's release will need (version bump, declared skills, Native
# Federation frontend). !cancelled() so a failed build still reports the checks that need no bundle.
- name: Release checks
if: ${{ !cancelled() && github.event_name == 'pull_request' && steps.guard.outputs.build == 'true' }}
run: ./scripts/check-extension-pr.sh "origin/${{ github.base_ref }}"

- name: Upload bundles
if: steps.guard.outputs.build == 'true'
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: extension-bundles
path: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/image-arch-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,6 @@ jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Check duplocloud images publish amd64 and arm64
run: ./scripts/check-image-arches.sh
110 changes: 66 additions & 44 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
# Publishes each extension in this repo as a GitHub Release on push to main.
#
# For every extensions/<name>/ it builds the bundle and, ONLY when manifest.version is new (its
# <name>-v<version> tag doesn't exist yet), cuts a GitHub Release with extension.zip attached. Bumping
# manifest.version is therefore how you publish a new version to the extension library.
# For every extensions/<name>/ it builds the bundle and publishes one release per build, tagged
# <name>-v<version>-sdk-<sdkVersion> at the commit that was built. scripts/release-extensions.sh holds the rules:
# a build already released is skipped, and an extension whose content changed at a version that already has a
# release fails the job, so bumping manifest.version is how you publish a new version to the extension library.
#
# This file ships with the dev-kit. After scripts/init-project.sh makes the repo yours, set these repo secrets
# (same as Extension CI): DUPLO_HOST + DUPLO_TOKEN. The build fetches the host SDK feed from DUPLO_HOST, so it
# must be reachable from CI (a public DuploCloud, not localhost).
# must be reachable from CI (a public DuploCloud, not localhost). Turn on immutable releases in the repository's
# settings, too; the publish step warns on every release that lands mutable.
#
# NOTE: the hot-load/deploy step is intentionally COMMENTED OUT (see below). We publish releases but do not
# auto-install onto the portal, so the install-from-registry flow stays testable end to end. Uncomment the
# "Deploy" step to auto-hot-load on merge.
#
# In Duplo's own organizations (duplocloud, duplocloud-internal) the build is also signed. scripts/extension-sign.py
# writes extension.zip.sig from EXTENSION_SIGNING_KEY and EXTENSION_SIGNING_CERT, mapped below from the org secret
# CONSOLE_SIGNING_KEY and the org variable CONSOLE_SIGNING_CERT, before release-extensions.sh creates the release.
# A missing key fails the job there before anything is written. Outside those organizations the release ships the
# zip alone, since a customer's copy of this workflow holds no Duplo signing credential.
#
# Two guards sit in front of the AWS role. "Check the publisher allowlist" runs only in Duplo's organizations, and
# marks the repository listed only when .github/extension-publishers.json names it as a publisher. The
# configure-aws-credentials step then runs only when that step reports listed=true, since EXTENSION_PUBLISHER_ROLE_ARN
# trusts only those repositories through GitHub OIDC (id-token: write). An unlisted repository's release still ships
# signed, but release-extensions.sh never calls publish_build for it, so the job never reaches S3 or the console and
# never needs AWS credentials.

name: Extension Release

on:
Expand All @@ -19,17 +35,29 @@ on:
workflow_dispatch:

permissions:
contents: write # required to create GitHub Releases
contents: write # create GitHub Releases
id-token: write # assume the extension-publisher role through GitHub OIDC

concurrency:
# One release run per repository at a time, never canceled between its writes.
group: extension-release-${{ github.repository }}
cancel-in-progress: false

jobs:
release:
# A dispatch from any other branch skips the job, so only main's code ever releases, signs or publishes.
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
env:
DUPLO_TARGET: remote
DUPLO_HOST: ${{ secrets.DUPLO_HOST }}
DUPLO_TOKEN: ${{ secrets.DUPLO_TOKEN }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# Every tag and the history it points at: the publish step compares each released version's commit with
# this one to tell an unchanged extension from one that changed without a version bump.
fetch-depth: 0

- name: Discover extensions
id: guard
Expand All @@ -39,15 +67,7 @@ jobs:
if [ ${#dirs[@]} -gt 0 ]; then echo "build=true" >> "$GITHUB_OUTPUT"
else echo "build=false" >> "$GITHUB_OUTPUT"; echo "::notice::No extensions/*/manifest.json — nothing to release."; fi

- uses: actions/setup-dotnet@v6
if: steps.guard.outputs.build == 'true'
with:
dotnet-version: '8.0.x'
- uses: actions/setup-node@v7
if: steps.guard.outputs.build == 'true'
with:
node-version: '22'

# Runs in the builder container (scripts/_builder.sh) on the runner's Docker, with the digest-pinned toolchain.
- name: Build all extensions
if: steps.guard.outputs.build == 'true'
run: ./scripts/build-all.sh
Expand All @@ -58,35 +78,37 @@ jobs:
# if: steps.guard.outputs.build == 'true'
# run: ./scripts/deploy-all.sh

- name: Publish GitHub releases (only for new manifest.version)
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
if: steps.guard.outputs.build == 'true'
with:
python-version: '3.12'
- name: Install the signer
if: steps.guard.outputs.build == 'true'
run: pip install --require-hashes -r scripts/extension-sign.requirements.txt

# Upload and registration need the role. Only Duplo's organizations hold it, and only allowlisted repositories
# pass its trust, so the step is skipped everywhere else and release-extensions.sh never reaches S3 there.
- name: Check the publisher allowlist
id: allow
if: >-
steps.guard.outputs.build == 'true' &&
(github.repository_owner == 'duplocloud' || github.repository_owner == 'duplocloud-internal')
run: |
jq -e --arg r "$GITHUB_REPOSITORY" 'any(.publishers[]; .repository == $r)' .github/extension-publishers.json >/dev/null \
&& echo "listed=true" >> "$GITHUB_OUTPUT" || echo "listed=false" >> "$GITHUB_OUTPUT"
- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4
if: steps.allow.outputs.listed == 'true'
with:
role-to-assume: ${{ vars.EXTENSION_PUBLISHER_ROLE_ARN }}
aws-region: us-west-2

- name: Publish GitHub releases (one per new build)
if: steps.guard.outputs.build == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
shopt -s nullglob
published=0; skipped=0
for m in extensions/*/manifest.json extension/*/manifest.json extension/manifest.json; do
[ -f "$m" ] || continue
dir="$(dirname "$m")"
zip="$dir/dist/extension.zip"
if [ ! -f "$zip" ]; then
echo "::warning::$dir — no dist/extension.zip (build produced nothing); skipping."
continue
fi
name="$(jq -r '.name // (.id)' "$m")"
version="$(jq -r '.version' "$m")"
slug="$(basename "$dir")"
tag="${slug}-v${version}"
if gh release view "$tag" >/dev/null 2>&1; then
echo "==> $tag already released — skipping (bump manifest.version to publish a new one)."
skipped=$((skipped+1))
continue
fi
echo "==> Publishing release $tag ($name $version)"
gh release create "$tag" "$zip" \
--title "$name $version" \
--notes "Automated release of $name v$version from $slug (extension.zip attached)."
published=$((published+1))
done
echo "==> Released $published, skipped $skipped (already-published versions)."
EXTENSION_SIGNING_KEY: ${{ secrets.CONSOLE_SIGNING_KEY }}
EXTENSION_SIGNING_CERT: ${{ vars.CONSOLE_SIGNING_CERT }}
CONSOLE_API_KEY: ${{ secrets.CONSOLE_API_KEY }}
CONSOLE_URL: ${{ vars.CONSOLE_URL }}
BUNDLE_BUCKET: ${{ vars.BUNDLE_BUCKET }}
run: ./scripts/release-extensions.sh
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
*.zip
!packages/*.tgz
!.claude/skills/duplo-extension-dev/templates/helloworld/frontend/vendor/*.tgz
# The console's cross-language signing vectors (tests/extension_sign_vectors.py), not a build artifact.
!tests/vectors/*.zip

# The Terraform extension's source, vendored by run.sh (scripts/fetch-terraform-extension.sh).
# It is fetched, not authored here, so it does not belong in this repo's history — and without this
Expand Down
12 changes: 6 additions & 6 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -204,14 +204,14 @@ services:
# below). Both routes are live; the compose network is simply the preferred one.
builder:
profiles: [tools]
# Deliberately ONE level of interpolation, not a nested ${BUILDER_IMAGE:-…:${BUILDER_TAG:-latest}}:
# Deliberately ONE level of interpolation, not a nested ${BUILDER_IMAGE:-…:${BUILDER_TAG:-…}}:
# podman-compose 1.5 stops at the first '}' and appends the remainder literally, yielding
# "…busybox:1.36}" and an "invalid reference format" error even when BUILDER_IMAGE is set. Nothing
# is lost — BUILDER_TAG is resolved into BUILDER_IMAGE by scripts/_builder.sh:builder_resolve_image,
# which always exports it before compose is invoked, so the default below is only ever reached by a
# hand-run `compose run` with no BUILDER_IMAGE set (where it now means :latest regardless of
# BUILDER_TAG — use the build scripts, or set BUILDER_IMAGE outright, to pin another tag).
image: ${BUILDER_IMAGE:-quay.io/duplocloud/duplo-extension-builder:latest}
# is lost — scripts/_builder.sh:builder_resolve_image resolves BUILDER_TAG and exports BUILDER_IMAGE
# before every build, so the default below is only ever reached by a hand-run `compose run` with no
# BUILDER_IMAGE set. It is the digest-pinned toolchain regardless of BUILDER_TAG, and must match
# _BUILDER_PINNED_IMAGE there (use the build scripts, or set BUILDER_IMAGE outright, to pick another).
image: ${BUILDER_IMAGE:-quay.io/duplocloud/duplo-extension-builder@sha256:8185e6a74379ae99e145f55608b3bc17e3037e95e64a612a226fe0eb15c0dbcd}
# Unset by default, so the build runs on the host's native architecture. Set BUILDER_PLATFORM only
# to force one (e.g. linux/amd64 if a publish ever ends up amd64-only) — an unset value is dropped
# by compose, whereas copying duplo-ai-studio's linux/amd64 default would silently emulate a .NET
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ reader takes.
| [architecture.md](architecture.md) | How the studio, agent, UI, xterm, and Mongo fit together, and how the hot-load loop works |
| [configuration.md](configuration.md) | Every `.env` variable — default, effect, and which are managed for you |
| [cli-reference.md](cli-reference.md) | `run.sh`, `stop.sh`, `logs.sh`, and every script in `scripts/` — flags and behavior |
| [releasing-extensions.md](releasing-extensions.md) | What `release.yml` publishes, signing, and the extra steps inside DuploCloud's own organizations |
| [upgrading.md](upgrading.md) | Taking framework updates, and how image-tag pinning works |
| [troubleshooting.md](troubleshooting.md) | Symptoms, causes, fixes |
| [faq.md](faq.md) | Licensing, data, platform support, and what happens on upgrade |
Expand Down
Loading
Loading