From 5773df457058b01f8f871647a7645adb6626ed84 Mon Sep 17 00:00:00 2001 From: amaechiabuah Date: Thu, 8 Oct 2026 01:11:37 +0100 Subject: [PATCH 1/5] Name every release file the two-approval rule covers (cherry picked from commit a597c141d5af4de6d47482ac728fb2c825e140af) --- docs/releasing-extensions.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/releasing-extensions.md b/docs/releasing-extensions.md index 4de056e..818e913 100644 --- a/docs/releasing-extensions.md +++ b/docs/releasing-extensions.md @@ -128,8 +128,9 @@ pinned devkit commit, so joining the allowlist takes a PR in both repositories: repository added to the role's trust list there. Either alone leaves the id unable to publish, refused by AWS on one side or skipped by the allowlist check on the other. -The design calls for two approvals from the `devkit-maintainers` team on changes to this file, `release.yml`, -`release-extensions.sh` and the signer, though the ruleset enforcing that is not configured yet. +Changes to this file, `release.yml`, `release-extensions.sh`, `_publish.sh`, `_publishers.sh` and the signer +are meant to take two approvals from the `devkit-maintainers` team, through a path-scoped rule in devkit's +ruleset. Together those files decide what gets signed, uploaded and registered. ## The channels bucket and the license server From 7a7d936884130de71c949c1930a558cca9785dc9 Mon Sep 17 00:00:00 2001 From: amaechiabuah Date: Thu, 8 Oct 2026 15:37:26 +0100 Subject: [PATCH 2/5] Publish on register when the allowlist entry or a manual run opts in --- .github/workflows/release.yml | 6 ++++++ docs/releasing-extensions.md | 31 ++++++++++++++++++++++-------- scripts/_publish.sh | 25 ++++++++++++++++++++---- scripts/_publishers.sh | 9 +++++++++ scripts/release-extensions.sh | 7 ++++++- tests/_publish_stubs.sh | 3 +++ tests/test-extension-publishers.sh | 10 ++++++++++ tests/test-publish.sh | 21 +++++++++++++++++++- tests/test-release-extensions.sh | 29 ++++++++++++++++++++++++++++ 9 files changed, 127 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c132cb..d58bd86 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,6 +33,11 @@ on: push: branches: [main] workflow_dispatch: + inputs: + publish: + description: Publish each version this run registers. Allowlist entries with "publish" true publish regardless. + type: boolean + default: false permissions: contents: write # create GitHub Releases @@ -109,6 +114,7 @@ jobs: EXTENSION_SIGNING_KEY: ${{ secrets.CONSOLE_SIGNING_KEY }} EXTENSION_SIGNING_CERT: ${{ vars.CONSOLE_SIGNING_CERT }} CONSOLE_API_KEY: ${{ secrets.CONSOLE_API_KEY }} + EXTENSION_PUBLISH: ${{ inputs.publish }} CONSOLE_URL: ${{ vars.CONSOLE_URL }} BUNDLE_BUCKET: ${{ vars.BUNDLE_BUCKET }} run: ./scripts/release-extensions.sh diff --git a/docs/releasing-extensions.md b/docs/releasing-extensions.md index 818e913..c465162 100644 --- a/docs/releasing-extensions.md +++ b/docs/releasing-extensions.md @@ -27,8 +27,8 @@ frontend bundle ships without the file that Native Federation hosts need. In `duplocloud` and `duplocloud-internal`, the release also carries `extension.zip.sig`, and, for an id the allowlist maps to this repository, the build is uploaded to the channels bucket and registered as a new, -unpublished version with the license server. Nothing in this workflow ever publishes a version. A platform -owner does that separately, once the build has been verified. +unpublished version with the license server. A version stays unpublished, for a platform owner to publish +once the build has been verified, unless the extension opts in to publishing (see "Publishing on register"). Outside those two organizations, a release ships the zip alone. A customer's copy of this workflow holds no DuploCloud signing credential, so it signs nothing, uploads nothing, and registers nothing. @@ -90,9 +90,10 @@ A platform owner sets up each of these once in the console. Each one then serves 1. **The API key behind `CONSOLE_API_KEY`.** In the platform-owner team, create a custom role holding **View extension versions** and **Manage extension versions**, create a service account with that role, - and store its API key as the organization secret. The release job reads and creates only versions and - artifacts, and the allowlist supplies each extension's uuid, so the key needs no other capability. The - console's own guide covers the service-account workflow in `docs/platform_owner/extension-versions.md`. + and store its API key as the organization secret. The release job reads and creates versions and artifacts, + and publishes a version when opted in. Manage extension versions covers publishing, and the allowlist + supplies each extension's uuid, so the key needs no other capability. The console's own guide covers the + service-account workflow in `docs/platform_owner/extension-versions.md`. 2. **The signing key behind `CONSOLE_SIGNING_KEY` and `CONSOLE_SIGNING_CERT`.** The publishing team needs a namespace covering its manifest ids, and a signing key created under that team. The console returns the key's private PEM once, at creation, and that goes into the secret. The key's certificate goes into the @@ -106,7 +107,7 @@ A platform owner sets up each of these once in the console. Each one then serves ### Joining the allowlist `.github/extension-publishers.json` ships empty in devkit. Each entry maps one manifest id to one -repository and one console extension: +repository and one console extension, with an optional `"publish": true` (see "Publishing on register"): ```json { @@ -159,8 +160,22 @@ Registering the version with the license server follows the same shape. The scri its number before creating it, and an error saying the record already exists just means a concurrent run created it first, so it looks up again rather than failing. It registers the artifact for this SDK the same way, by looking it up before creating it. An existing artifact whose path, hash and signature all match -counts as already done. One that differs fails the job. The script never marks a version published. Every -version it creates starts unpublished, and stays that way until a platform owner publishes it. +counts as already done. One that differs fails the job. Every version it creates starts unpublished. + +### Publishing on register + +Publishing a version is what ships its build to every entitled install, so it is opt-in, in either of two ways. + +- **Per extension.** Set `"publish": true` on the extension's allowlist entry. Every version the release job + registers for it is then published. The flag sits in devkit's reviewed allowlist, not in the extension + repository, so turning it on takes the same review as joining the allowlist. +- **Per run.** Start the Extension Release workflow by hand with its `publish` input checked. That run + publishes every version it registers, and a push to `main` never does. A manual run also publishes a version + an earlier run already registered, since it resumes that release and finds the build already in place. + +Either way, the version is published only after its build is uploaded and its artifact is registered and +matches the release. A version that is already published is left alone. A repository builds against one SDK at +a time, so a version with builds for several SDKs is published once its first build is registered. ## Certificate reissue and key rotation diff --git a/scripts/_publish.sh b/scripts/_publish.sh index e2eae12..79f0e45 100644 --- a/scripts/_publish.sh +++ b/scripts/_publish.sh @@ -1,7 +1,9 @@ # shellcheck shell=bash # Sourced by scripts/release-extensions.sh. Signing is mandatory in Duplo's two organizations and skipped anywhere # else, so a customer copy of the release workflow behaves as before with no Duplo credential. Upload and registration -# run only in Duplo's organizations, for a manifest id the allowlist maps to this repository. +# run only in Duplo's organizations, for a manifest id the allowlist maps to this repository. A registered version +# stays unpublished unless the caller sets publish_version=1, which scripts/release-extensions.sh does when the +# allowlist entry sets "publish": true or a manual run asks for it. # duplo_org: exit 0 when this run belongs to one of Duplo's organizations. duplo_org() { case "${GITHUB_REPOSITORY_OWNER:-}" in duplocloud|duplocloud-internal) return 0 ;; *) return 1 ;; esac; } @@ -47,6 +49,20 @@ artifact_matches() { <<<"$1" >/dev/null } +# maybe_publish : mark the version published when publish_version=1 and it is not already. +# Runs only after the build's artifact is registered and matches the release, so a version is never published ahead +# of the build it ships. +maybe_publish() { + [ "${publish_version:-0}" = 1 ] || return 0 + local tag=$1 vuuid=$2 out + out="$(console GET "/api/extensions/$uuid/versions/$vuuid/")" \ + || { echo "::error::$tag — console lookup of the version to publish failed: $out"; return 1; } + if jq -e '.is_published == true' <<<"$out" >/dev/null; then echo "==> $tag's version is already published"; return 0; fi + out="$(console PATCH "/api/extensions/$uuid/versions/$vuuid/" '{"is_published": true}')" \ + || { echo "::error::$tag — console refused to publish the version: $out"; return 1; } + echo "==> published $tag in the license server" +} + # publish_build : upload the Release's zip and .sig, then register both. Reads the # assets from the Release so a re-run reuses the bytes already signed and released, with no rebuild. # @@ -74,7 +90,7 @@ publish_build() { out="$(console GET "/api/extensions/$uuid/versions/$vuuid/artifacts/?sdk_version=$sdk")" \ || { echo "::error::$tag — console lookup of the artifact failed: $out"; rm -rf "$dl"; return 1; } if artifact_matches "$out" "$(artifact_body "$key" "$sdk" "$sha" "$dl/extension.zip.sig")"; then - echo "==> $tag already published"; rm -rf "$dl"; return 0 + echo "==> $tag already uploaded and registered"; rm -rf "$dl"; maybe_publish "$tag" "$vuuid"; return fi fi fi @@ -108,11 +124,12 @@ publish_build() { || { echo "::error::$tag — console lookup of the artifact failed: $out"; rm -rf "$dl"; return 1; } if [ "$(jq 'length' <<<"$out")" -gt 0 ]; then if artifact_matches "$out" "$art"; then - echo "==> $tag already registered"; rm -rf "$dl"; return 0 + echo "==> $tag already registered"; rm -rf "$dl"; maybe_publish "$tag" "$vuuid"; return fi echo "::error::$tag — the console already holds a build for SDK $sdk that differs from this release."; rm -rf "$dl"; return 1 fi out="$(console POST "/api/extensions/$uuid/versions/$vuuid/artifacts/" "$art")" \ || { echo "::error::$tag — console refused the artifact: $out"; rm -rf "$dl"; return 1; } - echo "==> registered $tag in the license server (unpublished)"; rm -rf "$dl" + echo "==> registered $tag in the license server"; rm -rf "$dl" + maybe_publish "$tag" "$vuuid" } diff --git a/scripts/_publishers.sh b/scripts/_publishers.sh index 1381e7d..daeed7b 100644 --- a/scripts/_publishers.sh +++ b/scripts/_publishers.sh @@ -20,3 +20,12 @@ publisher_extension_uuid() { [ -n "$uuid" ] || return 1 printf '%s\n' "$uuid" } + +# publisher_auto_publish : exit 0 when that repository's entry for the id sets "publish": true, +# so every version the release job registers for it is also published. Sits in this reviewed file rather than in +# each extension repo, so turning it on takes the same review as joining the allowlist. +publisher_auto_publish() { + [ -f "$publishers_file" ] || return 1 + jq -e --arg id "$1" --arg repo "$2" \ + 'any(.publishers[]; .manifestId == $id and .repository == $repo and .publish == true)' "$publishers_file" >/dev/null 2>&1 +} diff --git a/scripts/release-extensions.sh b/scripts/release-extensions.sh index 3692328..2fc5264 100755 --- a/scripts/release-extensions.sh +++ b/scripts/release-extensions.sh @@ -21,7 +21,8 @@ # signing credential. Within a Duplo organization, the manifest id also has to be in the extension publisher # allowlist (scripts/_publishers.sh) for the repository, or the release still ships signed but scripts/_publish.sh's # publish_build is never called for it. An allowlisted id with no CONSOLE_API_KEY fails before it is signed or -# released, since that build could be released but never registered. +# released, since that build could be released but never registered. A registered version stays unpublished unless +# its allowlist entry sets "publish": true or EXTENSION_PUBLISH=true (a manual run's publish input). # # Resuming: a tag that already has a release is never rebuilt, re-signed or re-released. Its own assets, read back # with gh release view, decide what happens next: a signed release calls publish_build again so a run that stopped @@ -95,6 +96,10 @@ for m in extensions/*/manifest.json extension/*/manifest.json extension/manifest echo "::notice::$dir — $id is not in the extension publisher allowlist for $repo, so it is released but not published to the license server." fi fi + # Publishing is opt-in, per extension through its allowlist entry or per run through EXTENSION_PUBLISH (a manual + # run's publish input). publish_build reads publish_version. + publish_version=0 + if [ -n "$uuid" ] && { [ "${EXTENSION_PUBLISH:-}" = true ] || publisher_auto_publish "$id" "$repo"; }; then publish_version=1; fi # Registration needs the console key, so a missing one fails here, before a release that could never be registered. if [ -n "$uuid" ] && [ -z "${CONSOLE_API_KEY:-}" ]; then echo "::error::$dir — $id is allowlisted for $repo, but CONSOLE_API_KEY is not set, so it cannot be registered." diff --git a/tests/_publish_stubs.sh b/tests/_publish_stubs.sh index e721da6..eda2d27 100644 --- a/tests/_publish_stubs.sh +++ b/tests/_publish_stubs.sh @@ -66,6 +66,9 @@ case "$method $url" in [ -n "${RACE:-}" ] && [ ! -f "$CONSOLE/raced" ] && { touch "$CONSOLE/raced"; echo '[{"uuid":"v-1"}]' > "$CONSOLE/versions.json"; echo '{"version":["1.0.0 already exists for this extension."]}'; exit 22; } [ -n "${VERSION_VANISHES:-}" ] && { echo '{"uuid":"v-1"}'; exit 0; } echo '[{"uuid":"v-1"}]' > "$CONSOLE/versions.json"; echo '{"uuid":"v-1"}' ;; + "GET "*"/versions/v-1/") + if [ -f "$CONSOLE/published" ]; then echo '{"uuid":"v-1","is_published":true}'; else echo '{"uuid":"v-1","is_published":false}'; fi ;; + "PATCH "*"/versions/v-1/") echo "$data" > "$CONSOLE/published"; echo '{"uuid":"v-1","is_published":true}' ;; "GET "*"/artifacts/?sdk_version="*) cat "$CONSOLE/artifacts.json" 2>/dev/null || echo '[]' ;; "POST "*"/artifacts/") echo "[$data]" > "$CONSOLE/artifacts.json"; echo "$data" ;; esac diff --git a/tests/test-extension-publishers.sh b/tests/test-extension-publishers.sh index fe71d22..c346646 100755 --- a/tests/test-extension-publishers.sh +++ b/tests/test-extension-publishers.sh @@ -23,6 +23,16 @@ for F in "$REAL" "$FIXTURE"; do "$F" >/dev/null && ok || bad "shape" done +t "publish, where an entry sets it, is a boolean" +jq -e 'all(.publishers[]; (has("publish") | not) or (.publish | type == "boolean"))' "$REAL" >/dev/null && ok || bad "publish is not a boolean" + +t "publisher_auto_publish is true only for an entry that sets publish true" +jq '.publishers[0].publish = true' "$FIXTURE" > "$TMP/auto.json" +id="$(jq -r '.publishers[0].manifestId' "$FIXTURE")"; repo="$(jq -r '.publishers[0].repository' "$FIXTURE")" +id2="$(jq -r '.publishers[1].manifestId' "$FIXTURE")"; repo2="$(jq -r '.publishers[1].repository' "$FIXTURE")" +( publishers_file="$TMP/auto.json"; publisher_auto_publish "$id" "$repo" && ! publisher_auto_publish "$id2" "$repo2" \ + && ! publisher_auto_publish "$id" "other/repo" ) && ok || bad "auto-publish lookup" + t "the real file is empty or every entry passes the shape check" jq -e '(.publishers | length == 0) or all(.publishers[]; (.repository | test("^[^/]+/[^/]+$")) and (.consoleExtension | test("^[0-9a-f-]{36}$")))' \ diff --git a/tests/test-publish.sh b/tests/test-publish.sh index 7bf4902..7a4d394 100755 --- a/tests/test-publish.sh +++ b/tests/test-publish.sh @@ -35,7 +35,7 @@ fi t "a re-run of a finished build stops at the console lookup, with no zip download and no aws call" : > "$LOG" # Keep S3/CONSOLE state from the prior run. Clear only the log, so this run's own calls are what we check. OUT=$(publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo 2>&1); RC=$? -[ $RC = 0 ] && grep -q "already published" <<<"$OUT" && ! grep -q "^aws " "$LOG" && ! grep -q "POST" <<<"$(grep curl "$LOG")" \ +[ $RC = 0 ] && grep -q "already uploaded and registered" <<<"$OUT" && ! grep -q "^aws " "$LOG" && ! grep -q "POST" <<<"$(grep curl "$LOG")" \ && grep -q -- "-p extension.zip.sig" "$LOG" && ! grep -qE -- "-p extension\.zip( |$)" "$LOG" && ok || bad "rc=$RC out=$OUT log=$(cat "$LOG")" t "a console lookup failure on a finished build fails the job, with no aws call" @@ -90,6 +90,25 @@ t "an artifact lookup failure on a new build fails the job" fresh; OUT=$(CONSOLE_FAIL='/artifacts/\?sdk_version=' publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo 2>&1); RC=$? [ $RC != 0 ] && grep -q "::error::.*lookup of the artifact failed" <<<"$OUT" && [ ! -f "$CONSOLE/artifacts.json" ] && ok || bad "rc=$RC out=$OUT" +t "publishes a fresh build when asked, after its artifact is registered" +fresh; OUT=$(publish_version=1 publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo 2>&1); RC=$? +[ $RC = 0 ] && jq -e '.is_published == true' "$CONSOLE/published" >/dev/null && grep -q "published demo-v1.0.0-sdk-1.0.6" <<<"$OUT" \ + && [ "$(grep -n 'curl -sS --fail-with-body -X PATCH' "$LOG" | cut -d: -f1)" -gt "$(grep -n '/versions/v-1/artifacts/$' "$LOG" | tail -1 | cut -d: -f1)" ] \ + && ok || bad "rc=$RC out=$OUT" + +t "a re-run that asks to publish an already published version sends no PATCH" +: > "$LOG"; OUT=$(publish_version=1 publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo 2>&1); RC=$? +[ $RC = 0 ] && ! grep -q "X PATCH" "$LOG" && ! grep -q "^aws " "$LOG" && ok || bad "rc=$RC out=$OUT" + +t "a finished, unpublished build is published by a re-run that asks, with no aws call" +fresh; publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo >/dev/null 2>&1; : > "$LOG" +OUT=$(publish_version=1 publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo 2>&1); RC=$? +[ $RC = 0 ] && [ "$(grep -c "X PATCH" "$LOG")" = 1 ] && ! grep -q "^aws " "$LOG" && ok || bad "rc=$RC out=$OUT" + +t "a failed publish fails the job" +fresh; OUT=$(CONSOLE_FAIL='/versions/v-1/$' publish_version=1 publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo 2>&1); RC=$? +[ $RC != 0 ] && grep -q "::error::.*publish" <<<"$OUT" && ok || bad "rc=$RC out=$OUT" + t "a version lookup that still comes back empty after the POST fails the job" fresh; OUT=$(VERSION_VANISHES=1 publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo 2>&1); RC=$? [ $RC != 0 ] && grep -q "no version uuid" <<<"$OUT" && ok || bad "rc=$RC out=$OUT" diff --git a/tests/test-release-extensions.sh b/tests/test-release-extensions.sh index 46d9126..65c2095 100755 --- a/tests/test-release-extensions.sh +++ b/tests/test-release-extensions.sh @@ -208,6 +208,35 @@ if [ "$RC" = 0 ] && ! grep -q "release create" "$GH_LOG" && [ ! -f "$REPO/extens && jq -e --arg s "$ZSHA19" '.[0] | .sha256 == $s and .signature == "SIGBYTES"' "$TMP/console.19/artifacts.json" >/dev/null then ok; else bad "rc=$RC out=$OUT"; fi +# resume_run [VAR=value ...]: an existing signed release of an allowlisted extension, resumed with the +# given environment, against its own scratch bucket and console. +resume_run() { + local c=$1; shift + new_repo; git -C "$REPO" tag demo-v0.1.0-sdk-1.0.6; build_zip 1.0.6 + mkdir -p "$c/assets" "$c/s3" "$c/console" + printf 'ZIPBYTES' > "$c/assets/extension.zip"; printf 'SIGBYTES' > "$c/assets/extension.zip.sig" + ( + export ASSETS="$c/assets" S3="$c/s3" CONSOLE="$c/console" CONSOLE_API_KEY=secret-key GITHUB_REPOSITORY=duplocloud/demo \ + GITHUB_REPOSITORY_OWNER=duplocloud EXTENSION_SIGNING_KEY=k EXTENSION_SIGNING_CERT=c + ZSHA="$(shasum -a 256 "$c/assets/extension.zip" | cut -d' ' -f1)"; export ZSHA + for kv in "$@"; do export "${kv?}"; done + run + ) +} + +t "an allowlist entry with publish true publishes the version it registers" +jq '.publishers[0].publish = true' "$TMP/pub.json" > "$TMP/pub-publish.json" +OUT="$(resume_run "$TMP/p1" PUBLISHERS_FILE="$TMP/pub-publish.json")"; RC=$? +[ "$RC" = 0 ] && [ -f "$TMP/p1/console/published" ] && ok || bad "rc=$RC out=$OUT" + +t "a run with EXTENSION_PUBLISH=true publishes the version it registers" +OUT="$(resume_run "$TMP/p2" PUBLISHERS_FILE="$TMP/pub.json" EXTENSION_PUBLISH=true)"; RC=$? +[ "$RC" = 0 ] && [ -f "$TMP/p2/console/published" ] && ok || bad "rc=$RC out=$OUT" + +t "without either opt-in, a registered version stays unpublished" +OUT="$(resume_run "$TMP/p3" PUBLISHERS_FILE="$TMP/pub.json")"; RC=$? +[ "$RC" = 0 ] && [ ! -f "$TMP/p3/console/published" ] && grep -q "registered" <<<"$OUT" && ok || bad "rc=$RC out=$OUT" + t "a release cut before signing warns with the bump and does not fail" new_repo; git -C "$REPO" tag demo-v0.1.0-sdk-1.0.6; build_zip 1.0.6 OUT="$(GH_ASSETS=extension.zip PUBLISHERS_FILE="$TMP/pub.json" GITHUB_REPOSITORY=duplocloud/demo GITHUB_REPOSITORY_OWNER=duplocloud \ From 86ac0288cf674f80cfb1484f3f9695f50dd5e5d2 Mon Sep 17 00:00:00 2001 From: amaechiabuah Date: Thu, 8 Oct 2026 20:54:24 +0100 Subject: [PATCH 3/5] Publish from a push only a build that run newly registers, and say what gates the publish flag --- docs/releasing-extensions.md | 15 ++++++++++----- scripts/_publish.sh | 24 +++++++++++++++--------- scripts/_publishers.sh | 4 ++-- scripts/release-extensions.sh | 15 ++++++++++----- tests/test-publish.sh | 21 ++++++++++++++++----- tests/test-release-extensions.sh | 9 +++++++++ 6 files changed, 62 insertions(+), 26 deletions(-) diff --git a/docs/releasing-extensions.md b/docs/releasing-extensions.md index c465162..10a5f5f 100644 --- a/docs/releasing-extensions.md +++ b/docs/releasing-extensions.md @@ -166,17 +166,22 @@ counts as already done. One that differs fails the job. Every version it creates Publishing a version is what ships its build to every entitled install, so it is opt-in, in either of two ways. -- **Per extension.** Set `"publish": true` on the extension's allowlist entry. Every version the release job - registers for it is then published. The flag sits in devkit's reviewed allowlist, not in the extension - repository, so turning it on takes the same review as joining the allowlist. +- **Per extension.** Set `"publish": true` on the extension's allowlist entry. A push to `main` then publishes a + version when that run newly registers its build. A push that resumes a release an earlier run registered never + publishes, so a version a platform owner unpublished stays unpublished. - **Per run.** Start the Extension Release workflow by hand with its `publish` input checked. That run - publishes every version it registers, and a push to `main` never does. A manual run also publishes a version - an earlier run already registered, since it resumes that release and finds the build already in place. + publishes every version it registers, and also a version an earlier run registered, since a person chose that + run. It is also how to publish a version whose publish failed after registration. Either way, the version is published only after its build is uploaded and its artifact is registered and matches the release. A version that is already published is left alone. A repository builds against one SDK at a time, so a version with builds for several SDKs is published once its first build is registered. +The release job reads the extension repository's own copy of the allowlist, so `"publish": true` is gated by +that repository's branch protection, not by devkit's review. Whoever can merge to the repository's `main` can turn +it on, much as they could change the release scripts the job runs. The console key that registers versions can +also publish them, since one capability covers both. + ## Certificate reissue and key rotation Reissuing the certificate alone, which a platform owner does as it nears expiry, changes only the diff --git a/scripts/_publish.sh b/scripts/_publish.sh index 79f0e45..f49145d 100644 --- a/scripts/_publish.sh +++ b/scripts/_publish.sh @@ -2,8 +2,8 @@ # Sourced by scripts/release-extensions.sh. Signing is mandatory in Duplo's two organizations and skipped anywhere # else, so a customer copy of the release workflow behaves as before with no Duplo credential. Upload and registration # run only in Duplo's organizations, for a manifest id the allowlist maps to this repository. A registered version -# stays unpublished unless the caller sets publish_version=1, which scripts/release-extensions.sh does when the -# allowlist entry sets "publish": true or a manual run asks for it. +# stays unpublished unless the caller sets publish_version (scripts/release-extensions.sh, from the allowlist entry's +# "publish" or a manual run's publish input). # duplo_org: exit 0 when this run belongs to one of Duplo's organizations. duplo_org() { case "${GITHUB_REPOSITORY_OWNER:-}" in duplocloud|duplocloud-internal) return 0 ;; *) return 1 ;; esac; } @@ -49,11 +49,17 @@ artifact_matches() { <<<"$1" >/dev/null } -# maybe_publish : mark the version published when publish_version=1 and it is not already. -# Runs only after the build's artifact is registered and matches the release, so a version is never published ahead -# of the build it ships. +# maybe_publish : mark the version published when publish_version allows it and it +# is not already. publish_version=new publishes only a build this call newly registered, so a push that resumes an +# existing tag never publishes and a deliberate unpublish sticks. publish_version=any, a manual run's choice, also +# publishes a build an earlier run registered. Runs only after the build's artifact is registered and matches the +# release, so a version is never published ahead of the build it ships. maybe_publish() { - [ "${publish_version:-0}" = 1 ] || return 0 + case "${publish_version:-}" in + any) ;; + new) [ "$3" = new ] || return 0 ;; + *) return 0 ;; + esac local tag=$1 vuuid=$2 out out="$(console GET "/api/extensions/$uuid/versions/$vuuid/")" \ || { echo "::error::$tag — console lookup of the version to publish failed: $out"; return 1; } @@ -90,7 +96,7 @@ publish_build() { out="$(console GET "/api/extensions/$uuid/versions/$vuuid/artifacts/?sdk_version=$sdk")" \ || { echo "::error::$tag — console lookup of the artifact failed: $out"; rm -rf "$dl"; return 1; } if artifact_matches "$out" "$(artifact_body "$key" "$sdk" "$sha" "$dl/extension.zip.sig")"; then - echo "==> $tag already uploaded and registered"; rm -rf "$dl"; maybe_publish "$tag" "$vuuid"; return + echo "==> $tag already uploaded and registered"; rm -rf "$dl"; maybe_publish "$tag" "$vuuid" existing; return fi fi fi @@ -124,12 +130,12 @@ publish_build() { || { echo "::error::$tag — console lookup of the artifact failed: $out"; rm -rf "$dl"; return 1; } if [ "$(jq 'length' <<<"$out")" -gt 0 ]; then if artifact_matches "$out" "$art"; then - echo "==> $tag already registered"; rm -rf "$dl"; maybe_publish "$tag" "$vuuid"; return + echo "==> $tag already registered"; rm -rf "$dl"; maybe_publish "$tag" "$vuuid" existing; return fi echo "::error::$tag — the console already holds a build for SDK $sdk that differs from this release."; rm -rf "$dl"; return 1 fi out="$(console POST "/api/extensions/$uuid/versions/$vuuid/artifacts/" "$art")" \ || { echo "::error::$tag — console refused the artifact: $out"; rm -rf "$dl"; return 1; } echo "==> registered $tag in the license server"; rm -rf "$dl" - maybe_publish "$tag" "$vuuid" + maybe_publish "$tag" "$vuuid" new } diff --git a/scripts/_publishers.sh b/scripts/_publishers.sh index daeed7b..6cd3554 100644 --- a/scripts/_publishers.sh +++ b/scripts/_publishers.sh @@ -22,8 +22,8 @@ publisher_extension_uuid() { } # publisher_auto_publish : exit 0 when that repository's entry for the id sets "publish": true, -# so every version the release job registers for it is also published. Sits in this reviewed file rather than in -# each extension repo, so turning it on takes the same review as joining the allowlist. +# so a build the release job newly registers for it is also published. The job reads the extension repository's own +# copy of this file, so the flag is gated by that repository's branch protection, not by devkit's review. publisher_auto_publish() { [ -f "$publishers_file" ] || return 1 jq -e --arg id "$1" --arg repo "$2" \ diff --git a/scripts/release-extensions.sh b/scripts/release-extensions.sh index 2fc5264..6dcb37d 100755 --- a/scripts/release-extensions.sh +++ b/scripts/release-extensions.sh @@ -22,7 +22,8 @@ # allowlist (scripts/_publishers.sh) for the repository, or the release still ships signed but scripts/_publish.sh's # publish_build is never called for it. An allowlisted id with no CONSOLE_API_KEY fails before it is signed or # released, since that build could be released but never registered. A registered version stays unpublished unless -# its allowlist entry sets "publish": true or EXTENSION_PUBLISH=true (a manual run's publish input). +# its allowlist entry sets "publish": true, which publishes only a build this run newly registers, or +# EXTENSION_PUBLISH=true (a manual run's publish input) asks to publish it. # # Resuming: a tag that already has a release is never rebuilt, re-signed or re-released. Its own assets, read back # with gh release view, decide what happens next: a signed release calls publish_build again so a run that stopped @@ -96,10 +97,14 @@ for m in extensions/*/manifest.json extension/*/manifest.json extension/manifest echo "::notice::$dir — $id is not in the extension publisher allowlist for $repo, so it is released but not published to the license server." fi fi - # Publishing is opt-in, per extension through its allowlist entry or per run through EXTENSION_PUBLISH (a manual - # run's publish input). publish_build reads publish_version. - publish_version=0 - if [ -n "$uuid" ] && { [ "${EXTENSION_PUBLISH:-}" = true ] || publisher_auto_publish "$id" "$repo"; }; then publish_version=1; fi + # Publishing is opt-in. An allowlist entry's "publish" publishes only a build this run newly registers, and a manual + # run's publish input (EXTENSION_PUBLISH) also publishes one an earlier run registered. publish_build reads + # publish_version, and _publish.sh's maybe_publish has the rule. + publish_version="" + if [ -n "$uuid" ]; then + if [ "${EXTENSION_PUBLISH:-}" = true ]; then publish_version=any + elif publisher_auto_publish "$id" "$repo"; then publish_version=new; fi + fi # Registration needs the console key, so a missing one fails here, before a release that could never be registered. if [ -n "$uuid" ] && [ -z "${CONSOLE_API_KEY:-}" ]; then echo "::error::$dir — $id is allowlisted for $repo, but CONSOLE_API_KEY is not set, so it cannot be registered." diff --git a/tests/test-publish.sh b/tests/test-publish.sh index 7a4d394..7579ab2 100755 --- a/tests/test-publish.sh +++ b/tests/test-publish.sh @@ -91,24 +91,35 @@ fresh; OUT=$(CONSOLE_FAIL='/artifacts/\?sdk_version=' publish_build demo-v1.0.0- [ $RC != 0 ] && grep -q "::error::.*lookup of the artifact failed" <<<"$OUT" && [ ! -f "$CONSOLE/artifacts.json" ] && ok || bad "rc=$RC out=$OUT" t "publishes a fresh build when asked, after its artifact is registered" -fresh; OUT=$(publish_version=1 publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo 2>&1); RC=$? +fresh; OUT=$(publish_version=new publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo 2>&1); RC=$? [ $RC = 0 ] && jq -e '.is_published == true' "$CONSOLE/published" >/dev/null && grep -q "published demo-v1.0.0-sdk-1.0.6" <<<"$OUT" \ && [ "$(grep -n 'curl -sS --fail-with-body -X PATCH' "$LOG" | cut -d: -f1)" -gt "$(grep -n '/versions/v-1/artifacts/$' "$LOG" | tail -1 | cut -d: -f1)" ] \ && ok || bad "rc=$RC out=$OUT" t "a re-run that asks to publish an already published version sends no PATCH" -: > "$LOG"; OUT=$(publish_version=1 publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo 2>&1); RC=$? +: > "$LOG"; OUT=$(publish_version=any publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo 2>&1); RC=$? [ $RC = 0 ] && ! grep -q "X PATCH" "$LOG" && ! grep -q "^aws " "$LOG" && ok || bad "rc=$RC out=$OUT" -t "a finished, unpublished build is published by a re-run that asks, with no aws call" +t "a manual run publishes a finished, unpublished build, with no aws call" fresh; publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo >/dev/null 2>&1; : > "$LOG" -OUT=$(publish_version=1 publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo 2>&1); RC=$? +OUT=$(publish_version=any publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo 2>&1); RC=$? [ $RC = 0 ] && [ "$(grep -c "X PATCH" "$LOG")" = 1 ] && ! grep -q "^aws " "$LOG" && ok || bad "rc=$RC out=$OUT" t "a failed publish fails the job" -fresh; OUT=$(CONSOLE_FAIL='/versions/v-1/$' publish_version=1 publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo 2>&1); RC=$? +fresh; OUT=$(CONSOLE_FAIL='/versions/v-1/$' publish_version=new publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo 2>&1); RC=$? [ $RC != 0 ] && grep -q "::error::.*publish" <<<"$OUT" && ok || bad "rc=$RC out=$OUT" +t "a version unpublished after an opted-in push is not published again by the next push" +fresh; publish_version=new publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo >/dev/null 2>&1 +rm -f "$CONSOLE/published"; : > "$LOG" +OUT=$(publish_version=new publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo 2>&1); RC=$? +[ $RC = 0 ] && ! grep -q "X PATCH" "$LOG" && [ ! -f "$CONSOLE/published" ] && ok || bad "rc=$RC out=$OUT" + +t "a push resuming a build an earlier run registered does not publish it" +fresh; publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo >/dev/null 2>&1; : > "$LOG" +OUT=$(publish_version=new publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo 2>&1); RC=$? +[ $RC = 0 ] && ! grep -q "X PATCH" "$LOG" && ok || bad "rc=$RC out=$OUT" + t "a version lookup that still comes back empty after the POST fails the job" fresh; OUT=$(VERSION_VANISHES=1 publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo 2>&1); RC=$? [ $RC != 0 ] && grep -q "no version uuid" <<<"$OUT" && ok || bad "rc=$RC out=$OUT" diff --git a/tests/test-release-extensions.sh b/tests/test-release-extensions.sh index 65c2095..774732c 100755 --- a/tests/test-release-extensions.sh +++ b/tests/test-release-extensions.sh @@ -229,6 +229,15 @@ jq '.publishers[0].publish = true' "$TMP/pub.json" > "$TMP/pub-publish.json" OUT="$(resume_run "$TMP/p1" PUBLISHERS_FILE="$TMP/pub-publish.json")"; RC=$? [ "$RC" = 0 ] && [ -f "$TMP/p1/console/published" ] && ok || bad "rc=$RC out=$OUT" +t "with publish true, a later push does not publish the version again once it is unpublished" +rm -f "$TMP/p1/console/published" +OUT="$(resume_run "$TMP/p1" PUBLISHERS_FILE="$TMP/pub-publish.json")"; RC=$? +[ "$RC" = 0 ] && [ ! -f "$TMP/p1/console/published" ] && ok || bad "rc=$RC out=$OUT" + +t "a manual run with EXTENSION_PUBLISH=true publishes a version an earlier run registered" +OUT="$(resume_run "$TMP/p1" PUBLISHERS_FILE="$TMP/pub.json" EXTENSION_PUBLISH=true)"; RC=$? +[ "$RC" = 0 ] && [ -f "$TMP/p1/console/published" ] && ok || bad "rc=$RC out=$OUT" + t "a run with EXTENSION_PUBLISH=true publishes the version it registers" OUT="$(resume_run "$TMP/p2" PUBLISHERS_FILE="$TMP/pub.json" EXTENSION_PUBLISH=true)"; RC=$? [ "$RC" = 0 ] && [ -f "$TMP/p2/console/published" ] && ok || bad "rc=$RC out=$OUT" From 88760d8d2ad8bd2b3725cd4959ea9ec7d22b54c0 Mon Sep 17 00:00:00 2001 From: amaechiabuah Date: Mon, 12 Oct 2026 14:50:55 +0100 Subject: [PATCH 4/5] Publish from a push only a version's first build, and name the manual retry when a publish fails --- .github/workflows/release.yml | 2 +- docs/releasing-extensions.md | 7 ++++--- scripts/_publish.sh | 24 ++++++++++++++++-------- tests/_publish_stubs.sh | 15 ++++++++++++--- tests/test-publish.sh | 12 +++++++++++- 5 files changed, 44 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d58bd86..070f487 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,7 +35,7 @@ on: workflow_dispatch: inputs: publish: - description: Publish each version this run registers. Allowlist entries with "publish" true publish regardless. + description: Publish each allowlisted extension's current version, including one an earlier run registered or a platform owner unpublished. Allowlist entries with "publish" true publish new builds regardless. type: boolean default: false diff --git a/docs/releasing-extensions.md b/docs/releasing-extensions.md index 10a5f5f..16c6220 100644 --- a/docs/releasing-extensions.md +++ b/docs/releasing-extensions.md @@ -143,7 +143,7 @@ default to the production bucket and the production console. A build that is already finished stops before any download of the zip or any S3 call. The script reads the Release's digest for `extension.zip`, downloads only `extension.zip.sig`, and looks up the version and its artifact for this SDK in the license server. An artifact whose path, hash and signature match is reported -as already published. Registration only ever follows both uploads, so a matching artifact means both objects +as already uploaded and registered. Registration only ever follows both uploads, so a matching artifact means both objects are already in the bucket. A failed lookup fails the job. Anything else takes the full path below. Otherwise, before uploading, the script downloads both assets from the GitHub Release itself, never @@ -167,8 +167,9 @@ counts as already done. One that differs fails the job. Every version it creates Publishing a version is what ships its build to every entitled install, so it is opt-in, in either of two ways. - **Per extension.** Set `"publish": true` on the extension's allowlist entry. A push to `main` then publishes a - version when that run newly registers its build. A push that resumes a release an earlier run registered never - publishes, so a version a platform owner unpublished stays unpublished. + version when that run registers its first build. A push that resumes a release an earlier run registered, or + adds another SDK's build to a version that already has one, never publishes, so a version a platform owner + unpublished stays unpublished. - **Per run.** Start the Extension Release workflow by hand with its `publish` input checked. That run publishes every version it registers, and also a version an earlier run registered, since a person chose that run. It is also how to publish a version whose publish failed after registration. diff --git a/scripts/_publish.sh b/scripts/_publish.sh index f49145d..2f7ea6a 100644 --- a/scripts/_publish.sh +++ b/scripts/_publish.sh @@ -50,10 +50,10 @@ artifact_matches() { } # maybe_publish : mark the version published when publish_version allows it and it -# is not already. publish_version=new publishes only a build this call newly registered, so a push that resumes an -# existing tag never publishes and a deliberate unpublish sticks. publish_version=any, a manual run's choice, also -# publishes a build an earlier run registered. Runs only after the build's artifact is registered and matches the -# release, so a version is never published ahead of the build it ships. +# is not already. publish_version=new publishes only the version's first build, newly registered by this call, so a +# push that resumes an existing tag or adds another SDK's build never publishes and a deliberate unpublish sticks. +# publish_version=any, a manual run's choice, also publishes a build an earlier run registered. Runs only after the +# build's artifact is registered and matches the release, so a version is never published ahead of the build it ships. maybe_publish() { case "${publish_version:-}" in any) ;; @@ -61,11 +61,13 @@ maybe_publish() { *) return 0 ;; esac local tag=$1 vuuid=$2 out + # A push re-run finds the build registered and does not publish it, so a manual run is what retries a failed publish. + local retry="Start Extension Release by hand with its publish input checked to retry" out="$(console GET "/api/extensions/$uuid/versions/$vuuid/")" \ - || { echo "::error::$tag — console lookup of the version to publish failed: $out"; return 1; } + || { echo "::error::$tag — console lookup of the version to publish failed: $out. $retry"; return 1; } if jq -e '.is_published == true' <<<"$out" >/dev/null; then echo "==> $tag's version is already published"; return 0; fi out="$(console PATCH "/api/extensions/$uuid/versions/$vuuid/" '{"is_published": true}')" \ - || { echo "::error::$tag — console refused to publish the version: $out"; return 1; } + || { echo "::error::$tag — console refused to publish the version: $out. $retry"; return 1; } echo "==> published $tag in the license server" } @@ -75,7 +77,7 @@ maybe_publish() { # A finished build stops early. Registration runs only after both uploads, so a registered artifact matching the # Release's digest and .sig means both objects are already in the bucket, and the run needs neither the zip nor S3. publish_build() { - local tag=$1 id=$2 version=$3 sdk=$4 v dl localsha sha key vuuid out art + local tag=$1 id=$2 version=$3 sdk=$4 v dl localsha sha key vuuid out art first for v in "$id" "$version" "$sdk"; do [[ "$v" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || { echo "::error::$tag — $v cannot form a bucket key."; return 1; } done @@ -134,8 +136,14 @@ publish_build() { fi echo "::error::$tag — the console already holds a build for SDK $sdk that differs from this release."; rm -rf "$dl"; return 1 fi + # A version's first build is new to it. A later SDK's build of a version that already has one is not, so a version a + # platform owner unpublished stays unpublished when an unchanged extension is rebuilt against a new SDK. + out="$(console GET "/api/extensions/$uuid/versions/$vuuid/artifacts/")" \ + || { echo "::error::$tag — console lookup of the version's builds failed: $out"; rm -rf "$dl"; return 1; } + first=existing + [ "$(jq 'length' <<<"$out")" -gt 0 ] || first=new out="$(console POST "/api/extensions/$uuid/versions/$vuuid/artifacts/" "$art")" \ || { echo "::error::$tag — console refused the artifact: $out"; rm -rf "$dl"; return 1; } echo "==> registered $tag in the license server"; rm -rf "$dl" - maybe_publish "$tag" "$vuuid" new + maybe_publish "$tag" "$vuuid" "$first" } diff --git a/tests/_publish_stubs.sh b/tests/_publish_stubs.sh index eda2d27..3cc37e8 100644 --- a/tests/_publish_stubs.sh +++ b/tests/_publish_stubs.sh @@ -68,9 +68,18 @@ case "$method $url" in echo '[{"uuid":"v-1"}]' > "$CONSOLE/versions.json"; echo '{"uuid":"v-1"}' ;; "GET "*"/versions/v-1/") if [ -f "$CONSOLE/published" ]; then echo '{"uuid":"v-1","is_published":true}'; else echo '{"uuid":"v-1","is_published":false}'; fi ;; - "PATCH "*"/versions/v-1/") echo "$data" > "$CONSOLE/published"; echo '{"uuid":"v-1","is_published":true}' ;; - "GET "*"/artifacts/?sdk_version="*) cat "$CONSOLE/artifacts.json" 2>/dev/null || echo '[]' ;; - "POST "*"/artifacts/") echo "[$data]" > "$CONSOLE/artifacts.json"; echo "$data" ;; + "PATCH "*"/versions/v-1/") + # PATCH_FAIL refuses the publish alone, so the version GET before it still answers. + [ -n "${PATCH_FAIL:-}" ] && { echo '{"detail":"refused"}'; exit 22; } + echo "$data" > "$CONSOLE/published"; echo '{"uuid":"v-1","is_published":true}' ;; + # Artifacts for every SDK sit in one list, filtered by sdk_version when the URL names one, so a version can hold + # builds for several SDKs. + "GET "*"/artifacts/?sdk_version="*) + jq -c --arg s "${url##*sdk_version=}" '[.[] | select(.sdk_version == $s)]' "$CONSOLE/artifacts.json" 2>/dev/null || echo '[]' ;; + "GET "*"/artifacts/") cat "$CONSOLE/artifacts.json" 2>/dev/null || echo '[]' ;; + "POST "*"/artifacts/") + jq -c --argjson a "$data" '. + [$a]' "$CONSOLE/artifacts.json" 2>/dev/null > "$CONSOLE/a.tmp" || echo "[$data]" > "$CONSOLE/a.tmp" + mv "$CONSOLE/a.tmp" "$CONSOLE/artifacts.json"; echo "$data" ;; esac EOF diff --git a/tests/test-publish.sh b/tests/test-publish.sh index 7579ab2..d617ff2 100755 --- a/tests/test-publish.sh +++ b/tests/test-publish.sh @@ -106,8 +106,18 @@ OUT=$(publish_version=any publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1 [ $RC = 0 ] && [ "$(grep -c "X PATCH" "$LOG")" = 1 ] && ! grep -q "^aws " "$LOG" && ok || bad "rc=$RC out=$OUT" t "a failed publish fails the job" +fresh; OUT=$(PATCH_FAIL=1 publish_version=new publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo 2>&1); RC=$? +[ $RC != 0 ] && grep -q "::error::.*refused to publish" <<<"$OUT" && grep -q "publish input" <<<"$OUT" && ok || bad "rc=$RC out=$OUT" + +t "a failed lookup before publishing fails the job and names the manual retry" fresh; OUT=$(CONSOLE_FAIL='/versions/v-1/$' publish_version=new publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo 2>&1); RC=$? -[ $RC != 0 ] && grep -q "::error::.*publish" <<<"$OUT" && ok || bad "rc=$RC out=$OUT" +[ $RC != 0 ] && grep -q "::error::.*lookup of the version to publish failed" <<<"$OUT" && grep -q "publish input" <<<"$OUT" && ok || bad "rc=$RC out=$OUT" + +t "a new SDK's build of an unpublished version does not publish it" +fresh; publish_version=new publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo >/dev/null 2>&1 +rm -f "$CONSOLE/published"; : > "$LOG" +OUT=$(publish_version=new publish_build demo-v1.0.0-sdk-1.0.7 duplo.demo 1.0.0 1.0.7 extensions/demo 2>&1); RC=$? +[ $RC = 0 ] && [ "$(jq length "$CONSOLE/artifacts.json")" = 2 ] && ! grep -q "X PATCH" "$LOG" && ok || bad "rc=$RC out=$OUT" t "a version unpublished after an opted-in push is not published again by the next push" fresh; publish_version=new publish_build demo-v1.0.0-sdk-1.0.6 duplo.demo 1.0.0 1.0.6 extensions/demo >/dev/null 2>&1 From 0345192b804630a002d3152f31e6e27226038af5 Mon Sep 17 00:00:00 2001 From: amaechiabuah Date: Mon, 12 Oct 2026 15:22:22 +0100 Subject: [PATCH 5/5] Add the signer's requirements file to the files the two-approval rule covers --- docs/releasing-extensions.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/releasing-extensions.md b/docs/releasing-extensions.md index 16c6220..5890af2 100644 --- a/docs/releasing-extensions.md +++ b/docs/releasing-extensions.md @@ -129,9 +129,10 @@ pinned devkit commit, so joining the allowlist takes a PR in both repositories: repository added to the role's trust list there. Either alone leaves the id unable to publish, refused by AWS on one side or skipped by the allowlist check on the other. -Changes to this file, `release.yml`, `release-extensions.sh`, `_publish.sh`, `_publishers.sh` and the signer -are meant to take two approvals from the `devkit-maintainers` team, through a path-scoped rule in devkit's -ruleset. Together those files decide what gets signed, uploaded and registered. +Changes to this file, `release.yml`, `release-extensions.sh`, `_publish.sh`, `_publishers.sh`, the signer and +its `extension-sign.requirements.txt` are meant to take two approvals from the `devkit-maintainers` team, +through a path-scoped rule in devkit's ruleset. Together those files decide what gets signed, uploaded and +registered, and the requirements file pins the libraries the signer loads the key into. ## The channels bucket and the license server