diff --git a/RELEASING.md b/RELEASING.md index 5a236d0..7b4bffc 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -6,11 +6,23 @@ GitHub Release, and publishes `@volcengine/rtc-cli` to npm. ## Version identity and channels -Every official `skills//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//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 +``` + | Tag | GitHub channel | npm channel | | --- | --- | --- | | `vX.Y.Z` | Latest release | `latest` | @@ -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; @@ -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. diff --git a/internal/releasecmd/main.go b/internal/releasecmd/main.go index 854389e..f9c3c15 100644 --- a/internal/releasecmd/main.go +++ b/internal/releasecmd/main.go @@ -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 } diff --git a/internal/releasecontract/contract.go b/internal/releasecontract/contract.go index e049357..e33e89a 100644 --- a/internal/releasecontract/contract.go +++ b/internal/releasecontract/contract.go @@ -14,7 +14,6 @@ import ( "path/filepath" "regexp" "sort" - "strconv" "strings" "gopkg.in/yaml.v3" @@ -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. @@ -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/. @@ -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: @@ -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 { @@ -113,8 +110,8 @@ 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: @@ -122,14 +119,9 @@ func Resolve(stability Stability, destination Destination, requested, baseline s 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) } @@ -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 @@ -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 { diff --git a/internal/releasecontract/contract_test.go b/internal/releasecontract/contract_test.go index dce22ac..43ee52d 100644 --- a/internal/releasecontract/contract_test.go +++ b/internal/releasecontract/contract_test.go @@ -35,34 +35,34 @@ 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) } }) @@ -70,13 +70,13 @@ func TestResolve(t *testing.T) { } 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) } } @@ -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"}, @@ -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) } @@ -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) } @@ -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) } diff --git a/internal/releasecontract/prepare.go b/internal/releasecontract/prepare.go index 0e3edb3..b37715c 100644 --- a/internal/releasecontract/prepare.go +++ b/internal/releasecontract/prepare.go @@ -81,7 +81,6 @@ func Prepare(options PrepareOptions) (manifest Manifest, err error) { } var preflightSkills []Skill - var baseline string if options.Source == SourceWorktree { if !options.AllowDirty { status, err := gitOutput(repoRoot, "status", "--porcelain=v1", "--untracked-files=all") @@ -92,7 +91,7 @@ func Prepare(options PrepareOptions) (manifest Manifest, err error) { return Manifest{}, errors.New("refusing dirty release source without explicit allow-dirty") } } - preflightSkills, baseline, err = DiscoverSkills(repoRoot) + preflightSkills, err = DiscoverSkills(repoRoot) if err != nil { return Manifest{}, err } @@ -126,18 +125,16 @@ func Prepare(options PrepareOptions) (manifest Manifest, err error) { } } - preparedSkills, preparedBaseline, err := DiscoverSkills(destination) + preparedSkills, err := DiscoverSkills(destination) if err != nil { return Manifest{}, fmt.Errorf("validate prepared source: %w", err) } if options.Source == SourceWorktree { - if preparedBaseline != baseline || !sameSkillSet(preflightSkills, preparedSkills) { + if !sameSkillSet(preflightSkills, preparedSkills) { return Manifest{}, errors.New("prepared Skill set differs from preflight Skill set") } - } else { - baseline = preparedBaseline } - identity, err := Resolve(options.Stability, options.Publication, options.Version, baseline) + identity, err := Resolve(options.Stability, options.Publication, options.Version) if err != nil { return Manifest{}, err } diff --git a/internal/releasecontract/prepare_test.go b/internal/releasecontract/prepare_test.go index c9c6d21..824827a 100644 --- a/internal/releasecontract/prepare_test.go +++ b/internal/releasecontract/prepare_test.go @@ -31,8 +31,8 @@ func releaseRepo(t *testing.T) string { t.Fatal(err) } t.Cleanup(func() { _ = os.RemoveAll(root) }) - writeSkill(t, root, "byted-sample-alpha", "1.2.3") - writeSkill(t, root, "byted-sample-beta", "1.2.3") + writeSkill(t, root, "byted-sample-alpha", SourceSkillVersion) + writeSkill(t, root, "byted-sample-beta", SourceSkillVersion) if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("committed\n"), 0o644); err != nil { t.Fatal(err) } @@ -133,11 +133,11 @@ func TestPrepareCommitIgnoresDirtyWorktree(t *testing.T) { t.Fatal(err) } destination := filepath.Join(t.TempDir(), "prepared") - manifest, err := Prepare(PrepareOptions{RepoRoot: root, Destination: destination, Stability: StabilityStable, Publication: DestinationPublic, Version: "v1.2.3", Source: SourceCommit, Ref: "HEAD"}) + manifest, err := Prepare(PrepareOptions{RepoRoot: root, Destination: destination, Stability: StabilityStable, Publication: DestinationPublic, Version: "v9.8.7", Source: SourceCommit, Ref: "HEAD"}) if err != nil { t.Fatal(err) } - if manifest.Source != "commit" || manifest.Version != "1.2.3" { + if manifest.Source != "commit" || manifest.Version != "9.8.7" { t.Fatalf("manifest=%+v", manifest) } wantCommit := strings.TrimSpace(git(t, root, "rev-parse", "HEAD")) diff --git a/internal/releasecontract/verify_test.go b/internal/releasecontract/verify_test.go index 05c74ab..69b32c4 100644 --- a/internal/releasecontract/verify_test.go +++ b/internal/releasecontract/verify_test.go @@ -119,8 +119,8 @@ func archiveFixture(t *testing.T, target Target, binary []byte, skills map[strin func verifiedFixture(t *testing.T) (Manifest, string, string, string) { t.Helper() - root := validSkills(t, "1.2.3") - skills, _, err := DiscoverSkills(root) + root := validSkills(t, SourceSkillVersion) + skills, err := DiscoverSkills(root) if err != nil { t.Fatal(err) } @@ -131,7 +131,7 @@ func verifiedFixture(t *testing.T) (Manifest, string, string, string) { skills[index].Version = "1.2.3-rc.1" } manifest := Manifest{ - Identity: Identity{Stability: StabilityPrerelease, Destination: DestinationPublic, Version: "1.2.3-rc.1", Baseline: "1.2.3"}, + Identity: Identity{Stability: StabilityPrerelease, Destination: DestinationPublic, Version: "1.2.3-rc.1"}, Source: "worktree", SourceRoot: root, SourceCommit: "fixture", SourceDate: "fixture-date", Skills: skills, Targets: releaseTargets("1.2.3-rc.1"), PackageName: "@volcengine/rtc-cli", ChecksumFile: "checksums.txt", } diff --git a/scripts/preflight-public-release.sh b/scripts/preflight-public-release.sh new file mode 100755 index 0000000..328dc3c --- /dev/null +++ b/scripts/preflight-public-release.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Beijing Volcano Engine Technology Ltd. +# SPDX-License-Identifier: MIT + +# Validate a requested public version against one exact committed source before +# an irreversible tag is created. This performs the same release-contract +# preparation used by the publication workflow, but does not build or publish. +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +stability="" +version="" +source_ref="" + +usage() { + cat <<'EOF' +Usage: scripts/preflight-public-release.sh --stability stable|prerelease --version VERSION --ref REF + +Builds the prepared source for one exact commit, stamps the requested version, +and validates the resulting release manifest. It writes no repository, tag, +release, or npm state. +EOF +} + +die() { + echo "preflight-public-release: $*" >&2 + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --stability) [[ $# -ge 2 ]] || die "--stability requires a value"; stability="$2"; shift 2 ;; + --version) [[ $# -ge 2 ]] || die "--version requires a value"; version="$2"; shift 2 ;; + --ref) [[ $# -ge 2 ]] || die "--ref requires a value"; source_ref="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done + +case "$stability" in + stable|prerelease) ;; + *) die "--stability must be stable or prerelease" ;; +esac +[[ -n "$version" ]] || die "--version is required" +[[ -n "$source_ref" ]] || die "--ref is required" +for command_name in git go jq; do + command -v "$command_name" >/dev/null 2>&1 || die "missing required command: $command_name" +done + +source_commit="$(git -C "$repo_root" rev-parse --verify "$source_ref^{commit}" 2>/dev/null || true)" +[[ -n "$source_commit" ]] || die "source ref is not a commit: $source_ref" + +tmp_root="$(mktemp -d)" +trap 'rm -rf "$tmp_root"' EXIT +prepared_source="$tmp_root/source" +manifest="$tmp_root/release-manifest.json" + +resolved_version="$(cd "$repo_root" && go run ./internal/releasecmd prepare \ + --repo "$repo_root" \ + --destination "$prepared_source" \ + --manifest "$manifest" \ + --stability "$stability" \ + --publication public \ + --version "$version" \ + --source commit \ + --ref "$source_commit")" +[[ -n "$resolved_version" && "$resolved_version" != null ]] || die "release contract returned an empty version" +jq -e --arg version "$resolved_version" \ + '(.skills | length) > 0 and all(.skills[]; .version == $version)' \ + "$manifest" >/dev/null || die "prepared Skill versions do not match $resolved_version" +prepared_skills="$(jq -r '.skills | length' "$manifest")" + +echo "preflight-public-release: version=$resolved_version" +echo "preflight-public-release: source_commit=$source_commit" +echo "preflight-public-release: prepared_skills=$prepared_skills" +echo "preflight-public-release: contract=passed" diff --git a/scripts/preflight-public-release_test.sh b/scripts/preflight-public-release_test.sh new file mode 100755 index 0000000..69c762e --- /dev/null +++ b/scripts/preflight-public-release_test.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Beijing Volcano Engine Technology Ltd. +# SPDX-License-Identifier: MIT + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +preflight="$repo_root/scripts/preflight-public-release.sh" + +[[ -x "$preflight" ]] || { + echo "preflight-public-release-test: missing executable $preflight" >&2 + exit 1 +} + +source_version="$(git -C "$repo_root" show HEAD:skills/byted-interactai-guide/SKILL.md | \ + awk '$1 == "version:" { print $2; exit }')" +source_version="${source_version#\"}" +source_version="${source_version%\"}" +source_version="${source_version#\'}" +source_version="${source_version%\'}" +[[ "$source_version" == "0.0.0-dev" ]] || { + echo "preflight-public-release-test: unexpected source Skill placeholder: $source_version" >&2 + exit 1 +} + +stable_version="9.8.7" +stable_output="$($preflight --stability stable --version "$stable_version" --ref HEAD)" +grep -Fqx "preflight-public-release: version=$stable_version" <<< "$stable_output" +grep -Fqx "preflight-public-release: source_commit=$(git -C "$repo_root" rev-parse HEAD)" <<< "$stable_output" +grep -Eq '^preflight-public-release: prepared_skills=[1-9][0-9]*$' <<< "$stable_output" +grep -Fqx "preflight-public-release: contract=passed" <<< "$stable_output" + +prerelease="4.5.6-rc.1" +prerelease_output="$($preflight --stability prerelease --version "$prerelease" --ref HEAD)" +grep -Fqx "preflight-public-release: version=$prerelease" <<< "$prerelease_output" + +if "$preflight" --stability stable --version 1.2 --ref HEAD >/dev/null 2>&1; then + echo "preflight-public-release-test: invalid stable version passed" >&2 + exit 1 +fi + +for reserved_version in 0.0.0-dev 0.0.0-snapshot; do + if "$preflight" --stability prerelease --version "$reserved_version" --ref HEAD >/dev/null 2>&1; then + echo "preflight-public-release-test: reserved version passed: $reserved_version" >&2 + exit 1 + fi +done + +echo "preflight-public-release-test: passed" diff --git a/skills/byted-interactai-guide/SKILL.md b/skills/byted-interactai-guide/SKILL.md index 2a9a3e9..9b8d9e3 100644 --- a/skills/byted-interactai-guide/SKILL.md +++ b/skills/byted-interactai-guide/SKILL.md @@ -1,7 +1,7 @@ --- name: byted-interactai-guide description: 解释火山 AI 音视频互动的产品能力、适用边界与最新官方文档;并帮助用户搭建、运行和分阶段排查最小 InteractAI VoiceChat Web Demo。用户询问产品支持情况、能力清单、接入方案或运行故障时使用。 -version: 0.0.1 +version: 0.0.0-dev --- # InteractAI Guide — 能力、接入与排障薄路由