feat(update): withdraw a published Studio version from the catalog

A release found broken after publication could only be fixed by shipping a
newer one, and installs kept being offered it meanwhile. The catalog now
carries a withdrawn list: a withdrawn version leaves the list old clients
read, a new client never offers or installs it and names it when it is the
one running, and a rerun of its own publication cannot list it again.
scripts/withdraw-version.sh edits the catalog; a manual workflow runs it
against the Studio catalog and keeps the GitHub release off the latest slot.
This commit is contained in:
yhh committed 2026-10-11 20:57:15 -07:00
1 parent 59b2b75e62
commit 8a4281abfb
9 files changed
+342 -4

No files matched your search

+1 -1
View File
@@ -88,7 +88,7 @@ jobs:
with:
go-version-file: go.mod
cache: true
- run: node --test scripts/ci-platform-scope.test.mjs scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
- run: node --test scripts/ci-platform-scope.test.mjs scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/withdraw-version.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
# A matrix job skipped at job level reports no per-leg checks at all, so
@@ -0,0 +1,84 @@
name: Withdraw a Studio version
# Pulls a published Studio release out of the update catalog (or puts it back),
# so installs stop being offered it without waiting for a new release. Only the
# catalog changes: the release's files stay where they are, and a withdrawn
# release can be restored with the same run.
on:
workflow_dispatch:
inputs:
version:
description: "Version to withdraw or restore, for example 2.36.0"
required: true
type: string
reason:
description: "Why it is withdrawn (shown to users on that version); ignored when restoring"
required: false
type: string
restore:
description: "Put a withdrawn version back instead of withdrawing it"
required: false
type: boolean
default: false
concurrency:
group: studio-catalog
cancel-in-progress: false
jobs:
withdraw:
name: ${{ inputs.restore && 'restore' || 'withdraw' }} ${{ inputs.version }}
runs-on: ubuntu-latest
timeout-minutes: 10
environment: studio-release
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: studio
persist-credentials: false
- name: Update the Studio catalog
env:
VERSION: ${{ inputs.version }}
REASON: ${{ inputs.reason }}
RESTORE: ${{ inputs.restore }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
run: |
if [ "$HAS_R2" != "true" ]; then
echo "::error::R2 credentials are not configured, so the catalog cannot be changed"
exit 1
fi
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
echo "::error::version must look like 2.36.0"
exit 1
fi
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
aws configure set default.region auto
endpoint="https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com"
at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
current=/tmp/studio-versions-current.json
next=/tmp/studio-versions-next.json
aws s3 cp "s3://${R2_BUCKET}/studio/versions.json" "$current" --endpoint-url "$endpoint"
if [ "$RESTORE" = "true" ]; then
bash scripts/withdraw-version.sh "$current" --restore "$VERSION" "$at" > "$next"
jq -e --arg v "$VERSION" '.versions | map(select((.version | ltrimstr("v")) == $v)) | length == 1' "$next" >/dev/null
else
bash scripts/withdraw-version.sh "$current" "$VERSION" "$REASON" "$at" > "$next"
jq -e --arg v "$VERSION" '(.versions | map(select((.version | ltrimstr("v")) == $v)) | length == 0) and (.withdrawn | map(select((.version | ltrimstr("v")) == $v)) | length == 1)' "$next" >/dev/null
fi
aws s3 cp "$next" "s3://${R2_BUCKET}/studio/versions.json" \
--endpoint-url "$endpoint" \
--content-type "application/json; charset=utf-8" \
--cache-control "public, max-age=300, stale-if-error=86400"
echo "Studio catalog updated for $VERSION (restore=$RESTORE)"
- name: Keep the GitHub release off the latest slot
if: ${{ !inputs.restore }}
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: gh release edit "studio-v${VERSION}" --prerelease --latest=false --repo "$GITHUB_REPOSITORY"
+6
View File
@@ -16,6 +16,9 @@ var (
ErrFetch = errors.New("update: the release could not be downloaded")
ErrVerify = errors.New("update: the release failed its signature check")
ErrStore = errors.New("update: the verified release could not be saved")
// ErrWithdrawn is a release the publisher pulled; naming it in a catalog row
// does not make it installable.
ErrWithdrawn = errors.New("update: the release was withdrawn")
)
// MaxSignatureSize caps a detached .minisig. A signature is a few hundred bytes;
@@ -52,6 +55,9 @@ func (u *Updater) ManifestFor(ctx context.Context, version string) (*Manifest, e
if err != nil {
return nil, err
}
if w, gone := idx.WithdrawnVersion(version); gone {
return nil, fmt.Errorf("%w: %s", ErrWithdrawn, w.Version)
}
for _, e := range idx.Versions {
if !SameVersion(e.Version, version) {
continue
+31 -1
View File
@@ -19,6 +19,28 @@ type Index struct {
SchemaVersion int `json:"schemaVersion"`
UpdatedAt string `json:"updatedAt"`
Versions []IndexEntry `json:"versions"`
// Withdrawn lists releases pulled after publication. A client that predates
// this field only reads Versions, so the publisher removes a withdrawn
// release from there as well: the field is what lets a newer client say why.
Withdrawn []WithdrawnEntry `json:"withdrawn,omitempty"`
}
// WithdrawnEntry is a release that must not be installed. It keeps the whole
// catalog row so withdrawing is reversible without losing what was published.
type WithdrawnEntry struct {
IndexEntry
Reason string `json:"reason,omitempty"`
WithdrawnAt string `json:"withdrawnAt,omitempty"`
}
// WithdrawnVersion reports whether version was withdrawn.
func (i *Index) WithdrawnVersion(version string) (WithdrawnEntry, bool) {
for _, w := range i.Withdrawn {
if SameVersion(w.Version, version) {
return w, true
}
}
return WithdrawnEntry{}, false
}
// IndexEntry is one published release, newest first in Index.Versions.
@@ -57,7 +79,15 @@ func FetchIndex(ctx context.Context, c *http.Client, url, userAgent string) (*In
kept = append(kept, e)
}
}
idx.Versions = kept
// Withdrawal wins over listing: a row the publisher left in both places is
// still not a release anyone may be moved onto.
listed := kept[:0]
for _, e := range kept {
if _, gone := idx.WithdrawnVersion(e.Version); !gone {
listed = append(listed, e)
}
}
idx.Versions = listed
return &idx, nil
}
+6
View File
@@ -97,6 +97,9 @@ type Status struct {
Newer bool
AutoOK bool // may be installed without asking; false while pinned
StalePin bool
// Withdrawn is set when the running version itself was pulled, so a panel
// can say so and point at Latest instead of presenting an ordinary update.
Withdrawn *WithdrawnEntry
Entries []IndexEntry
}
@@ -110,6 +113,9 @@ func (u *Updater) Check(ctx context.Context) (Status, error) {
return st, err
}
st.Entries = idx.Versions
if w, gone := idx.WithdrawnVersion(st.Current); gone {
st.Withdrawn = &w
}
for _, e := range idx.Versions {
if st.Latest == "" || CompareVersions(e.Version, st.Latest) > 0 {
st.Latest = e.Version
@@ -0,0 +1,58 @@
package update
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
const withdrawnCatalog = `{"schemaVersion":1,"versions":[
{"version":"2.1.0","tag":"studio-v2.1.0","manifest":"https://x/2.1.0/latest.json"},
{"version":"2.0.0","tag":"studio-v2.0.0","manifest":"https://x/2.0.0/latest.json"}],
"withdrawn":[
{"version":"2.1.0","tag":"studio-v2.1.0","manifest":"https://x/2.1.0/latest.json","reason":"blank window on first launch","withdrawnAt":"2026-10-12T00:00:00Z"},
{"version":"2.0.5","tag":"studio-v2.0.5","manifest":"https://x/2.0.5/latest.json","reason":"update loop"}]}`
func TestCheckNeverOffersAWithdrawnVersion(t *testing.T) {
st, err := checkAgainst(t, withdrawnCatalog, Options{Current: "2.0.0"})
if err != nil {
t.Fatalf("Check: %v", err)
}
if st.Latest != "2.0.0" || st.Newer || st.AutoOK {
t.Fatalf("status = %+v, want the withdrawn 2.1.0 neither offered nor installed", st)
}
for _, e := range st.Entries {
if e.Version == "2.1.0" {
t.Errorf("the catalog still lists the withdrawn 2.1.0: %+v", st.Entries)
}
}
}
func TestCheckNamesTheWithdrawnVersionTheUserRuns(t *testing.T) {
st, err := checkAgainst(t, withdrawnCatalog, Options{Current: "2.0.5"})
if err != nil {
t.Fatalf("Check: %v", err)
}
if st.Withdrawn == nil || st.Withdrawn.Version != "2.0.5" || st.Withdrawn.Reason != "update loop" {
t.Fatalf("Withdrawn = %+v, want the running 2.0.5 with its reason", st.Withdrawn)
}
clean, err := checkAgainst(t, withdrawnCatalog, Options{Current: "2.0.0"})
if err != nil || clean.Withdrawn != nil {
t.Fatalf("a version that was never withdrawn reads as one: %+v, %v", clean.Withdrawn, err)
}
}
func TestManifestForRefusesAWithdrawnVersion(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(withdrawnCatalog))
}))
t.Cleanup(srv.Close)
u := New(Options{IndexURL: srv.URL, HTTP: srv.Client()})
for _, version := range []string{"2.1.0", "2.0.5"} {
if _, err := u.ManifestFor(context.Background(), version); !errors.Is(err, ErrWithdrawn) {
t.Errorf("ManifestFor(%s) = %v, want ErrWithdrawn", version, err)
}
}
}
+7 -2
View File
@@ -76,7 +76,9 @@ printf '%s' "$base" | jq \
--arg notes "$notes" \
--argjson keep "$keep" '
def semver: [splits("[.-]")] | map(select(test("^[0-9]+$")) | tonumber);
{
([.withdrawn // [] | .[].version | ltrimstr("v")]) as $gone
| (.withdrawn // []) as $withdrawn
| {
schemaVersion: 1,
updatedAt: $publishedAt,
versions: (
@@ -91,9 +93,12 @@ printf '%s' "$base" | jq \
# Existing entries win: unique_by keeps the first of each group, so a
# rerun cannot rewrite what was already published under that version.
| unique_by(.version)
# A withdrawn release is never listed again, even by a rerun of its own
# publication: restoring is withdraw-version.sh --restore.
| map(select((.version | ltrimstr("v")) as $v | $gone | index($v) | not))
| sort_by(.version | semver)
| reverse
| .[0:$keep]
),
}
} + (if ($withdrawn | length) == 0 then {} else {withdrawn: $withdrawn} end)
'
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Withdraw a published release from the rollback index, or put it back.
#
# A withdrawn release leaves "versions" — the only list a client that predates
# the field reads, so those clients stop being offered it — and moves into
# "withdrawn" with its whole row and a reason, which is what lets a newer client
# say why and what restoring needs. Usage:
#
# withdraw-version.sh <index> <version> <reason> <at>
# withdraw-version.sh <index> --restore <version> <at>
#
# The updated index is printed; the caller uploads it.
set -euo pipefail
restore=false
if [ "${2:-}" = "--restore" ]; then
restore=true
if [ "$#" -ne 4 ]; then
echo "usage: $0 <index> --restore <version> <at>" >&2
exit 2
fi
index="$1" version="$3" at="$4" reason=""
else
if [ "$#" -ne 4 ]; then
echo "usage: $0 <index> <version> <reason> <at>" >&2
exit 2
fi
index="$1" version="$2" reason="$3" at="$4"
fi
version="${version#v}"
if [ ! -f "$index" ] || ! jq -e '.versions | type == "array"' "$index" >/dev/null; then
echo "::error::$index is not a versions index" >&2
exit 1
fi
if [ -z "$version" ] || [ -z "$at" ]; then
echo "::error::a version and a timestamp are required" >&2
exit 1
fi
if [ "$restore" = false ] && [ -z "${reason// }" ]; then
echo "::error::withdrawing a release needs a reason" >&2
exit 1
fi
jq --arg v "$version" --arg reason "$reason" --arg at "$at" --argjson restore "$restore" '
def same(x): ((.version | ltrimstr("v")) == x);
(.withdrawn // []) as $gone
| if $restore then
if ($gone | map(select(same($v))) | length) != 1 then error("version \($v) is not withdrawn") else . end
| def semver: [splits("[.-]")] | map(select(test("^[0-9]+$")) | tonumber);
.versions = ((.versions + [$gone[] | select(same($v)) | del(.reason, .withdrawnAt)])
| sort_by(.version | semver) | reverse)
| .withdrawn = [$gone[] | select(same($v) | not)]
| .updatedAt = $at
else
if ($gone | map(select(same($v))) | length) > 0 then error("version \($v) is already withdrawn") else . end
| if (.versions | map(select(same($v))) | length) != 1 then error("version \($v) is not in the index") else . end
| if (.versions | length) < 2 then error("the last remaining release cannot be withdrawn") else . end
| .withdrawn = ($gone + [.versions[] | select(same($v)) + {reason: $reason, withdrawnAt: $at}])
| .versions = [.versions[] | select(same($v) | not)]
| .updatedAt = $at
end
| if (.withdrawn | length) == 0 then del(.withdrawn) else . end
' "$index"
+85
View File
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
const dir = path.dirname(fileURLToPath(import.meta.url));
const indexScript = path.join(dir, "update-versions-index.sh");
const withdrawScript = path.join(dir, "withdraw-version.sh");
function run(script, ...args) {
const out = spawnSync("bash", [script, ...args], { encoding: "utf8" });
return { status: out.status, stderr: out.stderr, json: out.status === 0 ? JSON.parse(out.stdout) : null };
}
function catalog(...versions) {
let file = "-";
let json = null;
for (const v of versions) {
json = run(indexScript, file, `v${v}`, `studio-v${v}`, "studio", `2026-10-1${versions.indexOf(v)}T00:00:00Z`).json;
file = path.join(mkdtempSync(path.join(tmpdir(), "wd-")), "c.json");
writeFileSync(file, JSON.stringify(json));
}
return file;
}
const names = (rows) => rows.map((v) => v.version.replace(/^v/, ""));
const AT = "2026-10-12T02:00:00Z";
test("withdrawing moves the row out of versions and keeps it, with the reason", () => {
const file = catalog("2.36.0", "2.37.0");
const out = run(withdrawScript, file, "2.36.0", "blank window on first launch", AT);
assert.equal(out.status, 0, out.stderr);
assert.deepEqual(names(out.json.versions), ["2.37.0"]);
assert.equal(out.json.withdrawn.length, 1);
assert.equal(out.json.withdrawn[0].version, "v2.36.0");
assert.equal(out.json.withdrawn[0].reason, "blank window on first launch");
assert.equal(out.json.withdrawn[0].withdrawnAt, AT);
assert.match(out.json.withdrawn[0].manifest, /studio-v2\.36\.0\/latest\.json$/);
});
test("withdrawing an unknown or already withdrawn version is refused", () => {
const file = catalog("2.36.0", "2.37.0");
assert.notEqual(run(withdrawScript, file, "9.9.9", "no such release", AT).status, 0);
const once = run(withdrawScript, file, "2.36.0", "bad", AT);
const next = path.join(mkdtempSync(path.join(tmpdir(), "wd-")), "c.json");
writeFileSync(next, JSON.stringify(once.json));
assert.notEqual(run(withdrawScript, next, "2.36.0", "again", AT).status, 0);
});
test("the last remaining release cannot be withdrawn", () => {
const file = catalog("2.37.0");
assert.notEqual(run(withdrawScript, file, "2.37.0", "bad", AT).status, 0);
});
test("a reason is required", () => {
const file = catalog("2.36.0", "2.37.0");
assert.notEqual(run(withdrawScript, file, "2.36.0", "", AT).status, 0);
});
test("restoring puts the original row back and clears the withdrawal", () => {
const file = catalog("2.36.0", "2.37.0");
const gone = run(withdrawScript, file, "2.36.0", "bad", AT);
const mid = path.join(mkdtempSync(path.join(tmpdir(), "wd-")), "c.json");
writeFileSync(mid, JSON.stringify(gone.json));
const back = run(withdrawScript, mid, "--restore", "2.36.0", AT);
assert.equal(back.status, 0, back.stderr);
assert.deepEqual(names(back.json.versions), ["2.37.0", "2.36.0"]);
assert.equal((back.json.withdrawn ?? []).length, 0);
assert.equal("reason" in back.json.versions[1], false);
});
test("publishing a later release keeps the withdrawn list and never revives a withdrawn version", () => {
const file = catalog("2.36.0", "2.37.0");
const gone = run(withdrawScript, file, "2.36.0", "bad", AT);
const mid = path.join(mkdtempSync(path.join(tmpdir(), "wd-")), "c.json");
writeFileSync(mid, JSON.stringify(gone.json));
const next = run(indexScript, mid, "v2.38.0", "studio-v2.38.0", "studio", "2026-10-13T00:00:00Z");
assert.deepEqual(names(next.json.versions), ["2.38.0", "2.37.0"]);
assert.equal(next.json.withdrawn[0].version, "v2.36.0");
const rerun = run(indexScript, mid, "v2.36.0", "studio-v2.36.0", "studio", "2026-10-13T00:00:00Z");
assert.deepEqual(names(rerun.json.versions), ["2.37.0"]);
});