diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77b202af45..f5da60b8bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/withdraw-studio-version.yml b/.github/workflows/withdraw-studio-version.yml new file mode 100644 index 0000000000..4d38a986d4 --- /dev/null +++ b/.github/workflows/withdraw-studio-version.yml @@ -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" diff --git a/internal/platform/update/download.go b/internal/platform/update/download.go index c70ce0bdb3..442f56d286 100644 --- a/internal/platform/update/download.go +++ b/internal/platform/update/download.go @@ -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 diff --git a/internal/platform/update/index.go b/internal/platform/update/index.go index 15b075259c..476739571f 100644 --- a/internal/platform/update/index.go +++ b/internal/platform/update/index.go @@ -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 } diff --git a/internal/platform/update/updater.go b/internal/platform/update/updater.go index afafe8fae7..19bfa1dd5e 100644 --- a/internal/platform/update/updater.go +++ b/internal/platform/update/updater.go @@ -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 diff --git a/internal/platform/update/withdrawn_test.go b/internal/platform/update/withdrawn_test.go new file mode 100644 index 0000000000..d3b3de0a70 --- /dev/null +++ b/internal/platform/update/withdrawn_test.go @@ -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) + } + } +} diff --git a/scripts/update-versions-index.sh b/scripts/update-versions-index.sh index e400ede2f0..d10dcb2b09 100644 --- a/scripts/update-versions-index.sh +++ b/scripts/update-versions-index.sh @@ -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) ' diff --git a/scripts/withdraw-version.sh b/scripts/withdraw-version.sh new file mode 100755 index 0000000000..b30785bbd8 --- /dev/null +++ b/scripts/withdraw-version.sh @@ -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 +# withdraw-version.sh --restore +# +# 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 --restore " >&2 + exit 2 + fi + index="$1" version="$3" at="$4" reason="" +else + if [ "$#" -ne 4 ]; then + echo "usage: $0 " >&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" diff --git a/scripts/withdraw-version.test.mjs b/scripts/withdraw-version.test.mjs new file mode 100644 index 0000000000..829992270a --- /dev/null +++ b/scripts/withdraw-version.test.mjs @@ -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"]); +});