Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 26 additions & 8 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,23 @@ GitHub Release, and publishes `@volcengine/rtc-cli` to npm.

## Version identity and channels

Every official `skills/<name>/SKILL.md` shares one checked-in stable `X.Y.Z`
baseline. Stable tags must match that baseline; prerelease tags use the same
core version. One canonical version is reused for CLI metadata, embedded and
Every checked-in official `skills/<name>/SKILL.md` uses the reserved valid
SemVer placeholder `0.0.0-dev`. Release preparation treats the requested tag as
the version authority and replaces that placeholder only in an isolated source
copy. One canonical version is then reused for CLI metadata, embedded and
archived Skills, archive names, checksums, and npm metadata.

The public-source audit is version-independent. After the reviewed tree reaches
GitHub `main`, choose the release version and run the committed-source contract
preflight against that exact commit before requesting publication approval. A
later version change leaves the unchanged source review valid but invalidates
prepared release artifacts, release notes, and publication approval.

```bash
./scripts/preflight-public-release.sh \
--stability stable --version X.Y.Z --ref <github-main-commit>
```

| Tag | GitHub channel | npm channel |
| --- | --- | --- |
| `vX.Y.Z` | Latest release | `latest` |
Expand All @@ -24,6 +36,10 @@ while npm `next` resolves to `@volcengine/rtc-cli@0.0.1-rc.1` and npm

Before publishing, confirm:

- the requested version passed `preflight-public-release.sh` against the exact
GitHub `main` commit;
- every checked-in official Skill uses `0.0.0-dev`, while every prepared and
archived Skill uses the requested release version;
- the release commit is present on GitHub `main` and all required checks pass;
- `CHANGELOG.md`, `LICENSE`, `NOTICE`, the READMEs, and every official Skill are
ready;
Expand All @@ -43,16 +59,18 @@ make release-snapshot-test

## Publish

1. Prepare complete release notes.
2. Create an annotated SemVer tag on the reviewed GitHub `main` commit. Stable
1. After public-source review and GitHub synchronization, freeze the version and
run the committed-source contract preflight against exact GitHub `main`.
2. Prepare complete release notes.
3. Create an annotated SemVer tag on the reviewed GitHub `main` commit. Stable
tags use `vX.Y.Z`; prerelease tags use a SemVer suffix such as
`vX.Y.Z-rc.1`.
3. Push the tag without force. This triggers
4. Push the tag without force. This triggers
`.github/workflows/publish-release.yml`.
4. Wait for the workflow to build and verify the six platform archives plus
5. Wait for the workflow to build and verify the six platform archives plus
`checksums.txt`, publish npm, finalize the GitHub Release, and verify the
resulting channels.
5. Run `scripts/verify-release-publication.sh` with the tag, target commit, same
6. Run `scripts/verify-release-publication.sh` with the tag, target commit, same
release-notes file, expected channel flags, and workflow run ID for an
independent final check.

Expand Down
3 changes: 1 addition & 2 deletions internal/releasecmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,10 @@ func resolve(args []string) error {
stability := flags.String("stability", "", "stable, prerelease, or snapshot")
publication := flags.String("publication", "", "public or none")
version := flags.String("version", "", "requested version")
baseline := flags.String("baseline", "", "stable Skill baseline")
if err := flags.Parse(args); err != nil {
return err
}
identity, err := releasecontract.Resolve(releasecontract.Stability(*stability), releasecontract.Destination(*publication), *version, *baseline)
identity, err := releasecontract.Resolve(releasecontract.Stability(*stability), releasecontract.Destination(*publication), *version)
if err != nil {
return err
}
Expand Down
78 changes: 29 additions & 49 deletions internal/releasecontract/contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import (
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"

"gopkg.in/yaml.v3"
Expand All @@ -29,6 +28,11 @@ const (
StabilityStable Stability = "stable"
StabilityPrerelease Stability = "prerelease"
StabilitySnapshot Stability = "snapshot"

// SourceSkillVersion is a valid SemVer placeholder reserved for checked-in
// official Skills. Release preparation replaces it in an isolated copy.
SourceSkillVersion = "0.0.0-dev"
snapshotVersion = "0.0.0-snapshot"
)

// Destination identifies where a release may be published.
Expand All @@ -49,7 +53,6 @@ type Identity struct {
Stability Stability `json:"stability"`
Destination Destination `json:"destination"`
Version string `json:"version"`
Baseline string `json:"baseline"`
}

// Skill is one official Skill discovered from a direct child of skills/.
Expand Down Expand Up @@ -81,14 +84,11 @@ type Manifest struct {
}

// Resolve derives the canonical release identity from stability, destination,
// requested version, and the checked-in stable Skill baseline.
func Resolve(stability Stability, destination Destination, requested, baseline string) (Identity, error) {
baseline = strings.TrimSpace(strings.TrimPrefix(baseline, "v"))
if !stableVersion.MatchString(baseline) {
return Identity{}, fmt.Errorf("Skill baseline %q must be stable X.Y.Z SemVer", baseline)
}
// and the requested version. Checked-in Skill metadata is a source placeholder
// and is never a release-version authority.
func Resolve(stability Stability, destination Destination, requested string) (Identity, error) {
requested = strings.TrimSpace(strings.TrimPrefix(requested, "v"))
identity := Identity{Stability: stability, Destination: destination, Baseline: baseline}
identity := Identity{Stability: stability, Destination: destination}
switch destination {
case DestinationPublic, DestinationNone:
default:
Expand All @@ -102,9 +102,6 @@ func Resolve(stability Stability, destination Destination, requested, baseline s
if !stableVersion.MatchString(requested) {
return Identity{}, fmt.Errorf("public stable version %q must be a vX.Y.Z tag or X.Y.Z", requested)
}
if requested != baseline {
return Identity{}, fmt.Errorf("public stable version %q does not match Skill baseline %q", requested, baseline)
}
identity.Version = requested
case StabilityPrerelease:
if destination != DestinationPublic && destination != DestinationNone {
Expand All @@ -113,23 +110,18 @@ func Resolve(stability Stability, destination Destination, requested, baseline s
if !validPrereleaseVersion(requested) {
return Identity{}, fmt.Errorf("prerelease version %q must be valid X.Y.Z-prerelease SemVer", requested)
}
if coreVersion(requested) != baseline {
return Identity{}, fmt.Errorf("prerelease version %q is incompatible with Skill baseline %q", requested, baseline)
if requested == SourceSkillVersion || requested == snapshotVersion {
return Identity{}, fmt.Errorf("prerelease version %q is reserved for non-release use", requested)
}
identity.Version = requested
case StabilitySnapshot:
if destination != DestinationNone {
return Identity{}, fmt.Errorf("snapshot stability is incompatible with %q destination", destination)
}
if requested != "" {
return Identity{}, errors.New("snapshot version is derived from the Skill baseline and must not be supplied")
}
parts := stableVersion.FindStringSubmatch(baseline)
patch, err := strconv.ParseUint(parts[3], 10, 64)
if err != nil || patch == ^uint64(0) {
return Identity{}, fmt.Errorf("cannot increment snapshot baseline %q", baseline)
return Identity{}, errors.New("snapshot version is fixed by the release contract and must not be supplied")
}
identity.Version = fmt.Sprintf("%s.%s.%d-snapshot", parts[1], parts[2], patch+1)
identity.Version = snapshotVersion
default:
return Identity{}, fmt.Errorf("unknown release stability %q", stability)
}
Expand Down Expand Up @@ -161,44 +153,37 @@ func validPrereleaseVersion(version string) bool {
return true
}

func coreVersion(version string) string {
if index := strings.IndexByte(version, '-'); index >= 0 {
return version[:index]
}
return version
}

// DiscoverSkills validates and returns every official Skill under root/skills.
// All Skills must share one stable checked-in version.
func DiscoverSkills(root string) ([]Skill, string, error) {
// Checked-in Skills use SourceSkillVersion; release preparation stamps only an
// isolated copy with the requested release version.
func DiscoverSkills(root string) ([]Skill, error) {
skillsDir := filepath.Join(root, "skills")
problems, err := skillscan.CheckSkills(skillsDir)
if err != nil {
return nil, "", fmt.Errorf("validate official Skills: %w", err)
return nil, fmt.Errorf("validate official Skills: %w", err)
}
if len(problems) > 0 {
messages := make([]string, 0, len(problems))
for _, problem := range problems {
messages = append(messages, fmt.Sprintf("%s: %s", problem.SourceFile, problem.Reason))
}
return nil, "", fmt.Errorf("official Skill validation failed: %s", strings.Join(messages, "; "))
return nil, fmt.Errorf("official Skill validation failed: %s", strings.Join(messages, "; "))
}

entries, err := os.ReadDir(skillsDir)
if err != nil {
return nil, "", fmt.Errorf("read official Skills: %w", err)
return nil, fmt.Errorf("read official Skills: %w", err)
}
var skills []Skill
seen := map[string]string{}
baseline := ""
for _, entry := range entries {
entryPath := filepath.Join(skillsDir, entry.Name())
info, err := os.Lstat(entryPath)
if err != nil {
return nil, "", fmt.Errorf("inspect Skill %q: %w", entry.Name(), err)
return nil, fmt.Errorf("inspect Skill %q: %w", entry.Name(), err)
}
if info.Mode()&os.ModeSymlink != 0 {
return nil, "", fmt.Errorf("Skill path %q must not be a symlink", entry.Name())
return nil, fmt.Errorf("Skill path %q must not be a symlink", entry.Name())
}
if !entry.IsDir() {
continue
Expand All @@ -209,35 +194,30 @@ func DiscoverSkills(root string) ([]Skill, string, error) {
continue
}
if err != nil || !mainInfo.Mode().IsRegular() {
return nil, "", fmt.Errorf("Skill %q SKILL.md must be a regular file", entry.Name())
return nil, fmt.Errorf("Skill %q SKILL.md must be a regular file", entry.Name())
}
data, err := os.ReadFile(main)
if err != nil {
return nil, "", fmt.Errorf("read Skill %q: %w", entry.Name(), err)
return nil, fmt.Errorf("read Skill %q: %w", entry.Name(), err)
}
fm, err := parseFrontmatter(data)
if err != nil {
return nil, "", fmt.Errorf("parse Skill %q frontmatter: %w", entry.Name(), err)
return nil, fmt.Errorf("parse Skill %q frontmatter: %w", entry.Name(), err)
}
if previous, ok := seen[fm.Name]; ok {
return nil, "", fmt.Errorf("duplicate Skill name %q in %s and %s", fm.Name, previous, main)
return nil, fmt.Errorf("duplicate Skill name %q in %s and %s", fm.Name, previous, main)
}
seen[fm.Name] = main
if !stableVersion.MatchString(fm.Version) {
return nil, "", fmt.Errorf("Skill %q checked-in version %q must be stable X.Y.Z SemVer", fm.Name, fm.Version)
}
if baseline == "" {
baseline = fm.Version
} else if fm.Version != baseline {
return nil, "", fmt.Errorf("Skill %q version %q does not match baseline %q", fm.Name, fm.Version, baseline)
if fm.Version != SourceSkillVersion {
return nil, fmt.Errorf("Skill %q checked-in version %q must use source placeholder %q", fm.Name, fm.Version, SourceSkillVersion)
}
skills = append(skills, Skill{Name: fm.Name, Version: fm.Version, Path: filepath.ToSlash(filepath.Join("skills", entry.Name(), "SKILL.md"))})
}
if len(skills) == 0 {
return nil, "", errors.New("no official Skills containing SKILL.md were found")
return nil, errors.New("no official Skills containing SKILL.md were found")
}
sort.Slice(skills, func(i, j int) bool { return skills[i].Name < skills[j].Name })
return skills, baseline, nil
return skills, nil
}

type frontmatter struct {
Expand Down
60 changes: 30 additions & 30 deletions internal/releasecontract/contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,48 +35,48 @@ func validSkills(t *testing.T, version string) string {

func TestResolve(t *testing.T) {
tests := []struct {
name, requested, baseline, want string
stability Stability
destination Destination
wantErr string
name, requested, want string
stability Stability
destination Destination
wantErr string
}{
{name: "public stable tag", stability: StabilityStable, destination: DestinationPublic, requested: "v1.2.3", baseline: "1.2.3", want: "1.2.3"},
{name: "public prerelease tag", stability: StabilityPrerelease, destination: DestinationPublic, requested: "v1.2.3-rc.4", baseline: "1.2.3", want: "1.2.3-rc.4"},
{name: "non-publishing prerelease", stability: StabilityPrerelease, destination: DestinationNone, requested: "1.2.3-preview.4", baseline: "1.2.3", want: "1.2.3-preview.4"},
{name: "non-publishing snapshot", stability: StabilitySnapshot, destination: DestinationNone, baseline: "1.2.3", want: "1.2.4-snapshot"},
{name: "bad prerelease", stability: StabilityPrerelease, destination: DestinationPublic, requested: "1.2.3-rc.01", baseline: "1.2.3", wantErr: "valid"},
{name: "stable mismatch", stability: StabilityStable, destination: DestinationPublic, requested: "1.2.4", baseline: "1.2.3", wantErr: "does not match"},
{name: "prerelease mismatch", stability: StabilityPrerelease, destination: DestinationPublic, requested: "1.2.4-rc.1", baseline: "1.2.3", wantErr: "incompatible"},
{name: "snapshot supplied", stability: StabilitySnapshot, destination: DestinationNone, requested: "1.2.4-snapshot", baseline: "1.2.3", wantErr: "must not be supplied"},
{name: "stable none conflict", stability: StabilityStable, destination: DestinationNone, requested: "1.2.3", baseline: "1.2.3", wantErr: "incompatible"},
{name: "snapshot public conflict", stability: StabilitySnapshot, destination: DestinationPublic, baseline: "1.2.3", wantErr: "incompatible"},
{name: "unknown stability", stability: Stability("nightly"), destination: DestinationNone, baseline: "1.2.3", wantErr: "unknown release stability"},
{name: "unknown destination", stability: StabilityStable, destination: Destination("partner"), requested: "1.2.3", baseline: "1.2.3", wantErr: "unknown publication destination"},
{name: "public stable tag", stability: StabilityStable, destination: DestinationPublic, requested: "v9.8.7", want: "9.8.7"},
{name: "public prerelease tag", stability: StabilityPrerelease, destination: DestinationPublic, requested: "v4.5.6-rc.4", want: "4.5.6-rc.4"},
{name: "reserved source placeholder", stability: StabilityPrerelease, destination: DestinationPublic, requested: SourceSkillVersion, wantErr: "reserved"},
{name: "reserved snapshot identity", stability: StabilityPrerelease, destination: DestinationPublic, requested: snapshotVersion, wantErr: "reserved"},
{name: "non-publishing prerelease", stability: StabilityPrerelease, destination: DestinationNone, requested: "2.3.4-preview.4", want: "2.3.4-preview.4"},
{name: "non-publishing snapshot", stability: StabilitySnapshot, destination: DestinationNone, want: "0.0.0-snapshot"},
{name: "bad prerelease", stability: StabilityPrerelease, destination: DestinationPublic, requested: "1.2.3-rc.01", wantErr: "valid"},
{name: "snapshot supplied", stability: StabilitySnapshot, destination: DestinationNone, requested: "1.2.4-snapshot", wantErr: "must not be supplied"},
{name: "stable none conflict", stability: StabilityStable, destination: DestinationNone, requested: "1.2.3", wantErr: "incompatible"},
{name: "snapshot public conflict", stability: StabilitySnapshot, destination: DestinationPublic, wantErr: "incompatible"},
{name: "unknown stability", stability: Stability("nightly"), destination: DestinationNone, wantErr: "unknown release stability"},
{name: "unknown destination", stability: StabilityStable, destination: Destination("partner"), requested: "1.2.3", wantErr: "unknown publication destination"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Resolve(tt.stability, tt.destination, tt.requested, tt.baseline)
got, err := Resolve(tt.stability, tt.destination, tt.requested)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error=%v, want substring %q", err, tt.wantErr)
}
return
}
if err != nil || got.Version != tt.want || got.Baseline != tt.baseline || got.Stability != tt.stability || got.Destination != tt.destination {
if err != nil || got.Version != tt.want || got.Stability != tt.stability || got.Destination != tt.destination {
t.Fatalf("Resolve()=%+v, %v; want version %q", got, err, tt.want)
}
})
}
}

func TestDiscoverSkillsMultiple(t *testing.T) {
root := validSkills(t, "1.2.3")
skills, baseline, err := DiscoverSkills(root)
root := validSkills(t, SourceSkillVersion)
skills, err := DiscoverSkills(root)
if err != nil {
t.Fatal(err)
}
if baseline != "1.2.3" || len(skills) != 2 || skills[0].Name != "byted-sample-alpha" || skills[1].Name != "byted-sample-beta" {
t.Fatalf("skills=%+v baseline=%q", skills, baseline)
if len(skills) != 2 || skills[0].Name != "byted-sample-alpha" || skills[1].Name != "byted-sample-beta" {
t.Fatalf("skills=%+v", skills)
}
}

Expand All @@ -90,7 +90,7 @@ func TestDiscoverSkillsRejectsInvalidSet(t *testing.T) {
_ = os.RemoveAll(filepath.Join(root, "skills"))
_ = os.Mkdir(filepath.Join(root, "skills"), 0o755)
}, wantErr: "no official Skills"},
{name: "mismatch", mutate: func(t *testing.T, root string) { writeSkill(t, root, "byted-sample-beta", "1.2.4") }, wantErr: "does not match baseline"},
{name: "released version", mutate: func(t *testing.T, root string) { writeSkill(t, root, "byted-sample-beta", "1.2.4") }, wantErr: "source placeholder"},
{name: "malformed", mutate: func(t *testing.T, root string) {
_ = os.WriteFile(filepath.Join(root, "skills", "byted-sample-alpha", "SKILL.md"), []byte("---\nname: [\n---\n"), 0o644)
}, wantErr: "validation failed"},
Expand All @@ -114,16 +114,16 @@ func TestDiscoverSkillsRejectsInvalidSet(t *testing.T) {
t.Skipf("symlinks unavailable: %v", err)
}
}, wantErr: "must not be a symlink"},
{name: "prerelease baseline", mutate: func(t *testing.T, root string) {
{name: "other prerelease", mutate: func(t *testing.T, root string) {
writeSkill(t, root, "byted-sample-alpha", "1.2.3-dev")
writeSkill(t, root, "byted-sample-beta", "1.2.3-dev")
}, wantErr: "must be stable"},
}, wantErr: "source placeholder"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
root := validSkills(t, "1.2.3")
root := validSkills(t, SourceSkillVersion)
tt.mutate(t, root)
_, _, err := DiscoverSkills(root)
_, err := DiscoverSkills(root)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error=%v, want substring %q", err, tt.wantErr)
}
Expand All @@ -132,8 +132,8 @@ func TestDiscoverSkillsRejectsInvalidSet(t *testing.T) {
}

func TestStampSkillsPreservesOtherBytes(t *testing.T) {
root := validSkills(t, "1.2.3")
skills, _, err := DiscoverSkills(root)
root := validSkills(t, SourceSkillVersion)
skills, err := DiscoverSkills(root)
if err != nil {
t.Fatal(err)
}
Expand All @@ -148,7 +148,7 @@ func TestStampSkillsPreservesOtherBytes(t *testing.T) {
if err != nil {
t.Fatal(err)
}
want := strings.Replace(string(before), `version: "1.2.3"`, `version: "1.2.3-rc.9"`, 1)
want := strings.Replace(string(before), `version: "0.0.0-dev"`, `version: "1.2.3-rc.9"`, 1)
if string(after) != want || !strings.Contains(string(after), "sentinel: keep-me") {
t.Fatalf("unexpected stamped content:\n%s", after)
}
Expand Down
Loading