carlos / platform Public

settings-1 task 5: promote, rollback and fast-track follow the digest provenance rule

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 4c531a4fda1ae77c5eb03f6cbec1030fc10d5d33 parent 9a0f4b3
4 files changed, +471 −2
  • internal/release/digests.go +21 −0
  • internal/release/digestwords.go +23 −0
  • internal/release/release.go +67 −2
  • internal/release/writers_test.go +360 −0
diff --git a/internal/release/digests.go b/internal/release/digests.go
index b49d53dbb..d13f827d5 100644
--- a/internal/release/digests.go
+++ b/internal/release/digests.go
@@ -104,3 +104,24 @@ func alignedHistoryDigests(p *ChannelPointer) []string {
copy(out, p.HistoryDigests)
return out
}
+
+// inheritedDigests is the history half of the provenance rule for a write
+// that moves the head to version: each entry's digest copied verbatim from
+// the verified predecessor, aligned with the History the write builds, and
+// never computed (spec §4.6). Its length always equals that History's.
+func inheritedDigests(old *ChannelPointer, version string) []string {
+ hd := alignedHistoryDigests(old)
+ if old.Version != "" && old.Version != version {
+ hd = prependCapped(old.Digest, hd, historyMax)
+ }
+ return hd
+}
+
+// ResignNeededError is a move onto a history entry that carries no digest, on
+// a channel that carries some (spec §4.6): a rollback, or a promote of a build
+// already in the channel's history.
+type ResignNeededError struct{ App, Channel, Version string }
+
+func (e *ResignNeededError) Error() string {
+ return fmt.Sprintf(TextRollbackResign, e.App, e.Channel, e.Version, e.App, e.Channel)
+}
diff --git a/internal/release/digestwords.go b/internal/release/digestwords.go
new file mode 100644
index 000000000..a755d2d7b
--- /dev/null
+++ b/internal/release/digestwords.go
@@ -0,0 +1,23 @@
+package release
+
+// The pointer digest strings a member or operator reads from this package
+// (#253). These are DRAFT texts from copy-review/strings.json until Task 1's
+// review approves them; each is marked TODO-COPY and is replaced by the
+// approved text, pinned by approved_copy_digests_test.go.
+const (
+ // TODO-COPY digests.rollback-resign
+ // digests.rollback-resign: app, channel, version, app, channel.
+ TextRollbackResign = "rollback: %s %s would go back to %s, which has no signed digest. Run carlos channels resign --app %s %s first, or promote a newer build"
+ // TODO-COPY digests.marker-unreadable
+ // digests.marker-unreadable: verb, account, error.
+ TextMarkerUnreadable = "%s: the pointer digest marker for account %s could not be verified, so nothing was signed: %v"
+ // TODO-COPY digests.resign-changed
+ // digests.resign-changed: channel.
+ TextResignChanged = "%s moved since these digests were shown. Nothing was signed. Run the command again"
+ // TODO-COPY digests.resign-unreadable
+ // digests.resign-unreadable: version.
+ TextResignUnreadable = "%s has no readable manifest, so its digest cannot be confirmed. Promote a newer build instead"
+ // TODO-COPY digests.resign-no-marker
+ // digests.resign-no-marker: account, account.
+ TextResignNoMarker = "resign: pointer digests are not turned on for account %s. Upgrade every writer, then run carlos channels digests on --account-id %s first"
+)
diff --git a/internal/release/release.go b/internal/release/release.go
index b86e5cf97..f6a29b26d 100644
--- a/internal/release/release.go
+++ b/internal/release/release.go
@@ -770,8 +770,11 @@ func (sg Signer) claimRawPromote(ctx context.Context, s Store, acct, app, channe
if err := sg.allows(channel, time.Now().UTC()); err != nil {
return fmt.Errorf("promote: %w", err)
}
- // A channel only ever points at a version that was actually shipped.
- if _, err := GetManifest(ctx, s, acct, app, version); err != nil {
+ // A channel only ever points at a version that was actually shipped. The
+ // raw bytes are kept: they are what the publisher shipped, and the only
+ // bytes a head digest may be computed from (spec §4.6).
+ _, shippedRaw, err := GetManifestRaw(ctx, s, acct, app, version)
+ if err != nil {
return fmt.Errorf("promote: version %s is not shipped: %w", version, err)
}
pub := sg.verifyPub()
@@ -800,6 +803,33 @@ func (sg Signer) claimRawPromote(ctx context.Context, s Store, acct, app, channe
default:
return err
}
+ marker, merr := ReadDigestMarker(ctx, s, pub, acct)
+ if merr != nil {
+ return fmt.Errorf(TextMarkerUnreadable, "promote", acct, merr)
+ }
+ if marker != nil && version != old.Version {
+ // A promote onto a build the predecessor already lists is bound to
+ // that entry's signed digest (#253): without this it would sign
+ // whatever bytes the bucket holds under the name now, which is the
+ // replay the digests exist to stop. An approval-gated rollback runs
+ // as exactly this promote.
+ for i, v := range old.History {
+ if v != version {
+ continue
+ }
+ want := alignedHistoryDigests(old)[i]
+ switch {
+ case want != "":
+ if got := ManifestDigest(shippedRaw); got != want {
+ return fmt.Errorf("promote: %w: %s/%s hashes to %s, the pointer signed %s",
+ ErrManifestDigest, app, version, digestShort(got), digestShort(want))
+ }
+ case old.ChannelHasDigests():
+ return &ResignNeededError{App: app, Channel: channel, Version: version}
+ }
+ break
+ }
+ }
if expectVersion != "" && old.Version != expectVersion {
return fmt.Errorf("promote --retry renews a promotion: %s now names %s, not %s", channel, old.Version, expectVersion)
}
@@ -831,6 +861,16 @@ func (sg Signer) claimRawPromote(ctx context.Context, s Store, acct, app, channe
if old.Version != "" && old.Version != version {
next.History = prependCapped(old.Version, old.History, historyMax)
}
+ if marker != nil {
+ // The head is newly promoted, so its digest is the shipped bytes'.
+ // A renewal of the same head (--retry) promotes nothing new and
+ // copies it, like every inherited entry.
+ next.Digest = ManifestDigest(shippedRaw)
+ if old.Version == version {
+ next.Digest = old.Digest
+ }
+ next.HistoryDigests = inheritedDigests(old, version)
+ }
if len(rec) > 0 {
e := rec[0]
e.Version = version // the entry describes THIS promotion, whatever it said
@@ -1219,6 +1259,14 @@ func (sg Signer) claimRawRollbackAuthorized(ctx context.Context, s Store, acct,
return &AuthorizationError{Err: aerr}
}
}
+ marker, merr := ReadDigestMarker(ctx, s, pub, acct)
+ if merr != nil {
+ return fmt.Errorf(TextMarkerUnreadable, "rollback", acct, merr)
+ }
+ hd := alignedHistoryDigests(old)
+ if marker != nil && old.ChannelHasDigests() && hd[0] == "" {
+ return &ResignNeededError{App: app, Channel: channel, Version: old.History[0]}
+ }
next := &ChannelPointer{
Account: acct,
App: app,
@@ -1229,6 +1277,11 @@ func (sg Signer) claimRawRollbackAuthorized(ctx context.Context, s Store, acct,
// erase how the channel got here either.
Audit: old.Audit,
}
+ if marker != nil {
+ // Versions and digests swap together (spec §4.6).
+ next.Digest = hd[0]
+ next.HistoryDigests = prependCapped(old.Digest, hd[1:], historyMax)
+ }
wrapper, err := signPointer(sg, next)
if err != nil {
return err
@@ -1269,6 +1322,10 @@ func claimRawFastTrack(ctx context.Context, s Store, priv ed25519.PrivateKey, ac
if old.FastTrack {
return old.Version, nil
}
+ marker, merr := ReadDigestMarker(ctx, s, sg.verifyPub(), acct)
+ if merr != nil {
+ return "", fmt.Errorf(TextMarkerUnreadable, "fast-track", acct, merr)
+ }
rung, _ := Rung(channel)
next := &ChannelPointer{
Account: acct, App: app, Channel: channel, Version: old.Version,
@@ -1278,6 +1335,14 @@ func claimRawFastTrack(ctx context.Context, s Store, priv ed25519.PrivateKey, ac
FastTrack: true, At: time.Now().UTC().Truncate(time.Second),
}, old.Audit, auditMax),
}
+ if marker != nil {
+ // Verbatim, never fetched (spec §4.6): the same head, the same history.
+ next.Digest = old.Digest
+ next.HistoryDigests = alignedHistoryDigests(old)
+ if len(next.HistoryDigests) == 0 {
+ next.HistoryDigests = nil
+ }
+ }
wrapper, err := signPointer(sg, next)
if err != nil {
return "", err
diff --git a/internal/release/writers_test.go b/internal/release/writers_test.go
new file mode 100644
index 000000000..2ba4bff63
--- /dev/null
+++ b/internal/release/writers_test.go
@@ -0,0 +1,360 @@
+package release
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "path"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+func manifestDigestOf(t *testing.T, s Store, acct, app, ver string) string {
+ t.Helper()
+ raw, _, err := s.Get(context.Background(), ManifestKey(acct, app, ver))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return ManifestDigest(raw)
+}
+
+func readPtr(t *testing.T, s Store, pub []byte, ch string) *ChannelPointer {
+ t.Helper()
+ p, err := Channel(context.Background(), s, pub, "bab", "km", ch)
+ if err != nil {
+ t.Fatalf("Channel: %v", err)
+ }
+ return p
+}
+
+func TestWritersEmitNothingWithoutTheMarker(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one"))
+ shipRelease(t, s, "bab", "km", "v2", adoptPlatform, []byte("two"))
+ for _, v := range []string{"v1", "v2"} {
+ if err := Promote(ctx, s, priv, "bab", "km", "stable", v); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if p := readPtr(t, s, pub, "stable"); p.Digest != "" || p.HistoryDigests != nil {
+ t.Fatalf("no marker, yet digests: %+v", p)
+ }
+ raw, _, _ := s.Get(ctx, ChannelKey("bab", "km", "stable"))
+ if strings.Contains(string(raw), "digest") {
+ t.Fatal("the wrapper mentions digests with the marker off")
+ }
+}
+
+func TestPromoteSignsTheShippedDigestAndInheritsNothing(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one"))
+ shipRelease(t, s, "bab", "km", "v2", adoptPlatform, []byte("two"))
+ shipRelease(t, s, "bab", "km", "v3", adoptPlatform, []byte("three"))
+ if err := Promote(ctx, s, priv, "bab", "km", "stable", "v1"); err != nil { // a legacy pointer
+ t.Fatal(err)
+ }
+ markerOn(t, s, priv, "bab")
+ if err := Promote(ctx, s, priv, "bab", "km", "stable", "v2"); err != nil {
+ t.Fatal(err)
+ }
+ p := readPtr(t, s, pub, "stable")
+ if p.Digest != manifestDigestOf(t, s, "bab", "km", "v2") {
+ t.Fatalf("head digest %q is not v2's shipped manifest", p.Digest)
+ }
+ // Spec §8: promoting over a legacy pointer leaves inherited entries
+ // without digests, never computed.
+ if len(p.HistoryDigests) != 1 || p.HistoryDigests[0] != "" {
+ t.Fatalf("v1 was inherited from a legacy pointer, yet history_digests = %q", p.HistoryDigests)
+ }
+ if err := Promote(ctx, s, priv, "bab", "km", "stable", "v3"); err != nil {
+ t.Fatal(err)
+ }
+ p = readPtr(t, s, pub, "stable")
+ want := []string{manifestDigestOf(t, s, "bab", "km", "v2"), ""}
+ if p.History[0] != "v2" || p.HistoryDigests[0] != want[0] || p.HistoryDigests[1] != "" {
+ t.Fatalf("history %q / %q, want v2's signed digest copied and v1 still empty", p.History, p.HistoryDigests)
+ }
+}
+
+func TestHistoryCapTrimsDigestsWithVersions(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ markerOn(t, s, priv, "bab")
+ for i := 0; i < historyMax+3; i++ {
+ v := "v" + string(rune('a'+i))
+ shipRelease(t, s, "bab", "km", v, adoptPlatform, []byte(v))
+ if err := Promote(ctx, s, priv, "bab", "km", "canary/x", v); err != nil {
+ t.Fatal(err)
+ }
+ }
+ p := readPtr(t, s, pub, "canary/x")
+ if len(p.History) != historyMax || len(p.HistoryDigests) != historyMax {
+ t.Fatalf("history %d, digests %d; want %d each", len(p.History), len(p.HistoryDigests), historyMax)
+ }
+ for i, v := range p.History {
+ if p.HistoryDigests[i] != manifestDigestOf(t, s, "bab", "km", v) {
+ t.Fatalf("history[%d] %s carries another build's digest", i, v)
+ }
+ }
+}
+
+// Review Focus 2.
+func TestPromoteRefusesAnUnverifiableMarker(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one"))
+ other, _ := testKeys(t)
+ markerOn(t, s, other, "bab")
+ err := Promote(ctx, s, priv, "bab", "km", "stable", "v1")
+ if err == nil || !strings.Contains(err.Error(), "marker") {
+ t.Fatalf("Promote with a foreign marker = %v; want a refusal naming the marker", err)
+ }
+ if _, err := Channel(ctx, s, pub, "bab", "km", "stable"); err == nil {
+ t.Fatal("the refused promotion still wrote a pointer")
+ }
+}
+
+func TestRetryCopiesTheHeadDigest(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one"))
+ if err := Promote(ctx, s, priv, "bab", "km", "stable", "v1"); err != nil { // legacy head
+ t.Fatal(err)
+ }
+ markerOn(t, s, priv, "bab")
+ time.Sleep(1100 * time.Millisecond)
+ if _, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "stable", "v1", PromoteOptions{Retry: true, Hotfix: true}); err != nil {
+ t.Fatal(err)
+ }
+ if p := readPtr(t, s, pub, "stable"); p.Digest != "" {
+ t.Fatalf("a retry computed a digest for the head it renewed: %q", p.Digest)
+ }
+}
+
+func TestScopedPromoteCarriesDigests(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ root, pub := testKeys(t)
+ markerOn(t, s, root, "bab")
+ seed, grant, err := MintKeyGrant(root, "ci", []string{"canary"}, time.Hour)
+ if err != nil {
+ t.Fatal(err)
+ }
+ key, _ := ParseSigningKey(seed, "test")
+ sg, err := ScopedSigner(key, grant, pub)
+ if err != nil {
+ t.Fatal(err)
+ }
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one"))
+ if err := sg.Promote(ctx, s, "bab", "km", "canary/x", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ if p := readPtr(t, s, pub, "canary/x"); p.Digest != manifestDigestOf(t, s, "bab", "km", "v1") {
+ t.Fatalf("a scoped promotion signed digest %q", p.Digest)
+ }
+}
+
+func TestRollbackSwapsDigestsWithVersions(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ markerOn(t, s, priv, "bab")
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one"))
+ shipRelease(t, s, "bab", "km", "v2", adoptPlatform, []byte("two"))
+ for _, v := range []string{"v1", "v2"} {
+ if err := Promote(ctx, s, priv, "bab", "km", "stable", v); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := Rollback(ctx, s, priv, "bab", "km", "stable"); err != nil {
+ t.Fatal(err)
+ }
+ p := readPtr(t, s, pub, "stable")
+ if p.Version != "v1" || p.Digest != manifestDigestOf(t, s, "bab", "km", "v1") ||
+ p.History[0] != "v2" || p.HistoryDigests[0] != manifestDigestOf(t, s, "bab", "km", "v2") {
+ t.Fatalf("after rollback: %+v", p)
+ }
+}
+
+func TestRollbackOntoAnEntryWithoutADigestIsRefused(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one"))
+ shipRelease(t, s, "bab", "km", "v2", adoptPlatform, []byte("two"))
+ if err := Promote(ctx, s, priv, "bab", "km", "stable", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ markerOn(t, s, priv, "bab")
+ if err := Promote(ctx, s, priv, "bab", "km", "stable", "v2"); err != nil {
+ t.Fatal(err)
+ }
+ err := Rollback(ctx, s, priv, "bab", "km", "stable")
+ var rn *ResignNeededError
+ if !errors.As(err, &rn) || rn.Version != "v1" || err.Error() != fmt.Sprintf(TextRollbackResign, "km", "stable", "v1", "km", "stable") {
+ t.Fatalf("rollback onto a digest-less v1 = %v; want ResignNeededError naming v1", err)
+ }
+ if p := readPtr(t, s, pub, "stable"); p.Version != "v2" {
+ t.Fatalf("the refused rollback moved the channel to %s", p.Version)
+ }
+}
+
+// manifestCountingStore counts manifest reads, so a test can prove a writer never
+// fetched one.
+type manifestCountingStore struct {
+ Store
+ manifests atomic.Int32
+}
+
+func (c *manifestCountingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if strings.HasSuffix(key, "/manifest.json") {
+ c.manifests.Add(1)
+ }
+ return c.Store.Get(ctx, key)
+}
+
+func TestFastTrackKeepsTheSignedDigestsVerbatim(t *testing.T) {
+ base := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ shipRelease(t, base, "bab", "km", "v1", adoptPlatform, []byte("one"))
+ shipRelease(t, base, "bab", "km", "v2", adoptPlatform, []byte("two"))
+ if err := Promote(ctx, base, priv, "bab", "km", "stable", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ markerOn(t, base, priv, "bab")
+ if err := Promote(ctx, base, priv, "bab", "km", "stable", "v2"); err != nil {
+ t.Fatal(err)
+ }
+ before := readPtr(t, base, pub, "stable")
+ s := &manifestCountingStore{Store: base}
+ if _, err := FastTrack(ctx, s, priv, "bab", "km", "stable", "ops@example.com"); err != nil {
+ t.Fatal(err)
+ }
+ if n := s.manifests.Load(); n != 0 {
+ t.Fatalf("FastTrack read %d manifests; it must copy digests, never fetch them", n)
+ }
+ after := readPtr(t, base, pub, "stable")
+ if !after.FastTrack || after.Digest != before.Digest || len(after.HistoryDigests) != 1 || after.HistoryDigests[0] != before.HistoryDigests[0] {
+ t.Fatalf("FastTrack changed the digests: before %+v, after %+v", before, after)
+ }
+}
+
+func TestRebindKeepsDigests(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ markerOn(t, s, priv, "bab")
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one"))
+ if err := Promote(ctx, s, priv, "bab", "km", "stable", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ raw, _, _ := s.Get(ctx, ChannelKey("bab", "km", "stable"))
+ out, err := RebindPointer(priv, raw, "bab", "cdf", "km", "stable")
+ if err != nil {
+ t.Fatal(err)
+ }
+ p, err := verifyPointer(pub, out, "cdf", "km", "stable")
+ if err != nil || p.Digest != manifestDigestOf(t, s, "bab", "km", "v1") {
+ t.Fatalf("rebound pointer = %+v, %v; want the digest carried verbatim", p, err)
+ }
+}
+
+// reship deletes every object of a shipped version and ships the name again
+// with other bytes: the reused-name replay of #253.
+func reship(t *testing.T, s Store, acct, app, ver string, body []byte) {
+ t.Helper()
+ ctx := context.Background()
+ keys, err := s.List(ctx, path.Dir(ManifestKey(acct, app, ver))+"/")
+ if err != nil || len(keys) == 0 {
+ t.Fatalf("List %s: %v %v", ver, keys, err)
+ }
+ for _, k := range keys {
+ if err := s.Delete(ctx, k); err != nil {
+ t.Fatal(err)
+ }
+ }
+ shipRelease(t, s, acct, app, ver, adoptPlatform, body)
+}
+
+// Ruling P7: a promote onto a version in the predecessor's history is bound
+// to that entry's signed digest.
+func TestPromoteOfAHistoryEntryWithReshippedBytesIsRefused(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ markerOn(t, s, priv, "bab")
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one"))
+ shipRelease(t, s, "bab", "km", "v2", adoptPlatform, []byte("two"))
+ for _, v := range []string{"v1", "v2"} {
+ if err := Promote(ctx, s, priv, "bab", "km", "stable", v); err != nil {
+ t.Fatal(err)
+ }
+ }
+ before := readPtr(t, s, pub, "stable")
+ reship(t, s, "bab", "km", "v1", []byte("not what was promoted"))
+ err := Promote(ctx, s, priv, "bab", "km", "stable", "v1")
+ if !errors.Is(err, ErrManifestDigest) {
+ t.Fatalf("promote of a re-shipped history entry = %v; want ErrManifestDigest", err)
+ }
+ after := readPtr(t, s, pub, "stable")
+ if after.Version != before.Version || after.Digest != before.Digest || !after.PromotedAt.Equal(before.PromotedAt) {
+ t.Fatalf("the refused promote changed the pointer: %+v -> %+v", before, after)
+ }
+}
+
+func TestPromoteOfADigestlessHistoryEntryIsRefused(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one"))
+ shipRelease(t, s, "bab", "km", "v2", adoptPlatform, []byte("two"))
+ if err := Promote(ctx, s, priv, "bab", "km", "stable", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ markerOn(t, s, priv, "bab")
+ if err := Promote(ctx, s, priv, "bab", "km", "stable", "v2"); err != nil {
+ t.Fatal(err)
+ }
+ before := readPtr(t, s, pub, "stable")
+ err := Promote(ctx, s, priv, "bab", "km", "stable", "v1")
+ var rn *ResignNeededError
+ if !errors.As(err, &rn) || rn.Version != "v1" || rn.App != "km" || rn.Channel != "stable" {
+ t.Fatalf("promote of a digest-less history entry = %v; want ResignNeededError naming v1", err)
+ }
+ after := readPtr(t, s, pub, "stable")
+ if after.Version != before.Version || !after.PromotedAt.Equal(before.PromotedAt) {
+ t.Fatalf("the refused promote changed the pointer: %+v -> %+v", before, after)
+ }
+}
+
+func TestPromoteOfAnUnchangedHistoryEntrySignsItsDigest(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ markerOn(t, s, priv, "bab")
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one"))
+ shipRelease(t, s, "bab", "km", "v2", adoptPlatform, []byte("two"))
+ for _, v := range []string{"v1", "v2"} {
+ if err := Promote(ctx, s, priv, "bab", "km", "stable", v); err != nil {
+ t.Fatal(err)
+ }
+ }
+ signed := readPtr(t, s, pub, "stable").HistoryDigests[0]
+ if err := Promote(ctx, s, priv, "bab", "km", "stable", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ p := readPtr(t, s, pub, "stable")
+ if p.Version != "v1" || p.Digest != signed || p.Digest != manifestDigestOf(t, s, "bab", "km", "v1") {
+ t.Fatalf("promote back to v1: %+v; want the entry's signed digest %s", p, signed)
+ }
+}