feat: publish builds the .bnp itself — the artifact is the only publish form
ninja plugin publish now runs the build pipeline (codeless or wasm, same as `ninja plugin build`) and uploads the resulting .bnp; the legacy source-archive path is deleted (internal/archive removed). --bnp remains as an optional prebuilt-artifact override. Client-side guard mirrors the orchestrator verify gate: a non-empty manifest version that differs from plugin.mod fails before upload. The git-archive-era publish warnings (gitignored-tracked, untracked, submodules) described a ship mechanism that no longer exists; replaced with a single dirty-tree drift warning (--strict still aborts). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
2f065e3ed4
commit
feeb31139d
@ -17,7 +17,7 @@ import (
|
|||||||
|
|
||||||
core "git.dev.alexdunmow.com/block/core/plugin"
|
core "git.dev.alexdunmow.com/block/core/plugin"
|
||||||
|
|
||||||
"git.dev.alexdunmow.com/block/cli/internal/archive"
|
"git.dev.alexdunmow.com/block/cli/internal/bnp"
|
||||||
"git.dev.alexdunmow.com/block/cli/internal/creds"
|
"git.dev.alexdunmow.com/block/cli/internal/creds"
|
||||||
"git.dev.alexdunmow.com/block/cli/internal/orchclient"
|
"git.dev.alexdunmow.com/block/cli/internal/orchclient"
|
||||||
v1 "git.dev.alexdunmow.com/block/cli/internal/api/orchestrator/v1"
|
v1 "git.dev.alexdunmow.com/block/cli/internal/api/orchestrator/v1"
|
||||||
@ -682,7 +682,7 @@ func newPluginPublishCmd() *cobra.Command {
|
|||||||
var bnpFile string
|
var bnpFile string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "publish",
|
Use: "publish",
|
||||||
Short: "Publish a new version to the registry (source archive, or a prebuilt .bnp via --bnp)",
|
Short: "Build the .bnp artifact and publish it to the registry",
|
||||||
RunE: func(c *cobra.Command, _ []string) error {
|
RunE: func(c *cobra.Command, _ []string) error {
|
||||||
host, _ := c.Flags().GetString("host")
|
host, _ := c.Flags().GetString("host")
|
||||||
cr, err := creds.Load()
|
cr, err := creds.Load()
|
||||||
@ -752,26 +752,43 @@ func newPluginPublishCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// WO-WZ-011: --bnp <file> uploads a prebuilt wasm artifact (from
|
// A .bnp artifact is the ONLY thing publish ships — instances
|
||||||
// `ninja plugin build`) instead of a source archive. The orchestrator
|
// reject source archives at install time, so the legacy
|
||||||
// verifies the .bnp layout/ABI/name/version server-side and records
|
// source-archive path is gone. Default: build the artifact right
|
||||||
// its abi_version. Without --bnp, publish keeps shipping a source
|
// here (same pipeline as `ninja plugin build`). --bnp skips the
|
||||||
// archive (compiled in-container by the CMS reconciler) for
|
// build and uploads a prebuilt artifact instead. The orchestrator
|
||||||
// backwards compatibility until the CMS install path is wasm-native.
|
// verifies the .bnp layout/ABI/name/version server-side and
|
||||||
var (
|
// records its abi_version.
|
||||||
archiveBytes []byte
|
var archiveBytes []byte
|
||||||
artifactKind = "source archive"
|
|
||||||
)
|
|
||||||
if bnpFile != "" {
|
if bnpFile != "" {
|
||||||
archiveBytes, err = os.ReadFile(bnpFile)
|
archiveBytes, err = os.ReadFile(bnpFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read --bnp file: %w", err)
|
return fmt.Errorf("read --bnp file: %w", err)
|
||||||
}
|
}
|
||||||
artifactKind = ".bnp artifact"
|
|
||||||
} else {
|
} else {
|
||||||
archiveBytes, err = archive.BuildSourceArchive(".")
|
isCodeless, err := bnp.IsCodelessRepo(".")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("build archive: %w", err)
|
return err
|
||||||
|
}
|
||||||
|
var res *bnp.BuildResult
|
||||||
|
if isCodeless {
|
||||||
|
res, err = bnp.BuildCodeless(context.Background(), bnp.BuildOptions{Dir: "."})
|
||||||
|
} else {
|
||||||
|
res, err = bnp.Build(context.Background(), bnp.BuildOptions{Dir: "."})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build .bnp: %w", err)
|
||||||
|
}
|
||||||
|
printBuildSummary(res)
|
||||||
|
// Mirror the orchestrator's verify gate (manifest version must
|
||||||
|
// match the published version when set) so the drift fails
|
||||||
|
// here, before the upload, with a fixable message.
|
||||||
|
if res.Version != "" && res.Version != mod.Plugin.Version {
|
||||||
|
return fmt.Errorf("manifest version %s != plugin.mod version %s (sync Registration.Version with plugin.mod)", res.Version, mod.Plugin.Version)
|
||||||
|
}
|
||||||
|
archiveBytes, err = os.ReadFile(res.OutputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read built artifact: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -803,7 +820,7 @@ func newPluginPublishCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("publish: %w", err)
|
return fmt.Errorf("publish: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Printf("Published %s (%s, %d bytes)\n", mod.Coords(), artifactKind, len(archiveBytes))
|
fmt.Printf("Published %s (.bnp artifact, %d bytes)\n", mod.Coords(), len(archiveBytes))
|
||||||
for _, w := range pubResp.Msg.Warnings {
|
for _, w := range pubResp.Msg.Warnings {
|
||||||
fmt.Printf(" warning: %s\n", w)
|
fmt.Printf(" warning: %s\n", w)
|
||||||
}
|
}
|
||||||
@ -811,9 +828,9 @@ func newPluginPublishCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVar(&channel, "channel", "latest", "Channel to point at this version")
|
cmd.Flags().StringVar(&channel, "channel", "latest", "Channel to point at this version")
|
||||||
cmd.Flags().BoolVar(&strict, "strict", false, "Fail if the working tree is dirty (default: ship dirty trees via stash-create)")
|
cmd.Flags().BoolVar(&strict, "strict", false, "Fail if the working tree is dirty (default: warn and build from the working directory)")
|
||||||
cmd.Flags().BoolVar(&privateFlag, "private", false, "Publish as a private plugin under your active account's @private namespace")
|
cmd.Flags().BoolVar(&privateFlag, "private", false, "Publish as a private plugin under your active account's @private namespace")
|
||||||
cmd.Flags().StringVar(&bnpFile, "bnp", "", "Upload a prebuilt .bnp wasm artifact (from `ninja plugin build`) instead of a source archive")
|
cmd.Flags().StringVar(&bnpFile, "bnp", "", "Upload a prebuilt .bnp artifact (from `ninja plugin build`) instead of building one")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1028,9 +1045,8 @@ func runCmd(name string, args ...string) error {
|
|||||||
|
|
||||||
// checkRepoHasHEAD returns a friendlier error than the raw git failure when
|
// checkRepoHasHEAD returns a friendlier error than the raw git failure when
|
||||||
// the publish flow is invoked in a freshly-initialised repository that has
|
// the publish flow is invoked in a freshly-initialised repository that has
|
||||||
// no commits yet. Without this, `git stash create` reports "You do not have
|
// no commits yet (the auto-commit of plugin.mod and the dirty-tree check
|
||||||
// the initial commit yet" via exit status 128, which surfaces as an internal
|
// both need a HEAD to diff against).
|
||||||
// failure to the user.
|
|
||||||
func checkRepoHasHEAD(repoDir string) error {
|
func checkRepoHasHEAD(repoDir string) error {
|
||||||
cmd := exec.Command("git", "rev-parse", "--verify", "HEAD")
|
cmd := exec.Command("git", "rev-parse", "--verify", "HEAD")
|
||||||
cmd.Dir = repoDir
|
cmd.Dir = repoDir
|
||||||
@ -1040,27 +1056,6 @@ func checkRepoHasHEAD(repoDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// submodulePaths returns the configured submodule paths from .gitmodules.
|
|
||||||
// Reading the file directly (rather than running `git submodule status`) means
|
|
||||||
// we detect submodules that have been declared but not yet initialised.
|
|
||||||
func submodulePaths(repoDir string) []string {
|
|
||||||
cmd := exec.Command("git", "config", "--file", ".gitmodules", "--get-regexp", `submodule\..*\.path`)
|
|
||||||
cmd.Dir = repoDir
|
|
||||||
out, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var paths []string
|
|
||||||
for line := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") {
|
|
||||||
// each line is "submodule.<name>.path <path>"
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) >= 2 {
|
|
||||||
paths = append(paths, fields[len(fields)-1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return paths
|
|
||||||
}
|
|
||||||
|
|
||||||
func upsertPluginMod(scope, name, displayName, description, kind string, categories, tags []string, private bool) error {
|
func upsertPluginMod(scope, name, displayName, description, kind string, categories, tags []string, private bool) error {
|
||||||
const file = "plugin.mod"
|
const file = "plugin.mod"
|
||||||
existing, _ := os.ReadFile(file)
|
existing, _ := os.ReadFile(file)
|
||||||
@ -1129,90 +1124,33 @@ func writeMod(path string, m *core.ModFile) error {
|
|||||||
return os.WriteFile(path, []byte(b.String()), 0o644)
|
return os.WriteFile(path, []byte(b.String()), 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
// gitignoredTrackedWarning writes a warning to w if any tracked files
|
// emitPublishWarnings gates the publish on working-tree state. The .bnp is
|
||||||
// in repoDir match the active .gitignore — those files still ship in the
|
// built from the working directory while the version tag and registry
|
||||||
// archive, which is almost always not what the author wants.
|
// metadata point at commits — a dirty tree means the published artifact may
|
||||||
func gitignoredTrackedWarning(repoDir string, w io.Writer) {
|
// not match the tagged source.
|
||||||
cmd := exec.Command("git", "ls-files", "--cached", "--ignored", "--exclude-standard")
|
|
||||||
cmd.Dir = repoDir
|
|
||||||
out, _ := cmd.Output()
|
|
||||||
names := strings.TrimSpace(string(out))
|
|
||||||
if names == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = fmt.Fprintln(w, "warning: these tracked files match .gitignore and will still be shipped:")
|
|
||||||
for n := range strings.SplitSeq(names, "\n") {
|
|
||||||
_, _ = fmt.Fprintln(w, " "+n)
|
|
||||||
}
|
|
||||||
_, _ = fmt.Fprintln(w, " (run `git rm --cached <file>` to drop)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// untrackedFilesWarning writes a warning to w listing untracked files in
|
|
||||||
// repoDir. Fires on the dirty-tree publish path because `git stash create`
|
|
||||||
// only captures tracked content, so untracked files silently vanish from the
|
|
||||||
// archive.
|
|
||||||
func untrackedFilesWarning(repoDir string, w io.Writer) {
|
|
||||||
cmd := exec.Command("git", "ls-files", "--others", "--exclude-standard")
|
|
||||||
cmd.Dir = repoDir
|
|
||||||
out, _ := cmd.Output()
|
|
||||||
names := strings.TrimSpace(string(out))
|
|
||||||
if names == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = fmt.Fprintln(w, "warning: these untracked files will NOT be in the archive:")
|
|
||||||
for n := range strings.SplitSeq(names, "\n") {
|
|
||||||
_, _ = fmt.Fprintln(w, " "+n)
|
|
||||||
}
|
|
||||||
_, _ = fmt.Fprintln(w, " (run `git add <file>` if they should be shipped)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// emitPublishWarnings runs the publish-time warning helpers in the order the
|
|
||||||
// CLI expects:
|
|
||||||
//
|
//
|
||||||
// 1. gitignoredTrackedWarning is unconditional so the developer sees it even
|
// - allowDirty=false (--strict): a dirty tree aborts the publish.
|
||||||
// when the publish is about to abort under --strict.
|
// - allowDirty=true (the default): a dirty tree emits a drift warning
|
||||||
// 2. If allowDirty is false (i.e. --strict) the working tree must be clean —
|
// listing the changed/untracked paths, then the publish proceeds.
|
||||||
// a dirty tree returns an error and the remaining warnings (which are
|
|
||||||
// only useful on the proceeding-publish path) are skipped.
|
|
||||||
// 3. If allowDirty is true (the default) the untracked-files warning fires
|
|
||||||
// so the user knows those files won't be in the archive.
|
|
||||||
// 4. submoduleWarning is unconditional on the proceeding path because
|
|
||||||
// `git archive` does not recurse into submodules.
|
|
||||||
func emitPublishWarnings(repoDir string, allowDirty bool, w io.Writer) error {
|
func emitPublishWarnings(repoDir string, allowDirty bool, w io.Writer) error {
|
||||||
gitignoredTrackedWarning(repoDir, w)
|
cmd := exec.Command("git", "status", "--porcelain")
|
||||||
|
cmd.Dir = repoDir
|
||||||
|
out, _ := cmd.Output()
|
||||||
|
dirty := strings.TrimSpace(string(out))
|
||||||
|
if dirty == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if !allowDirty {
|
if !allowDirty {
|
||||||
cmd := exec.Command("git", "status", "--porcelain")
|
return fmt.Errorf("working tree dirty (--strict); commit your changes or drop --strict")
|
||||||
cmd.Dir = repoDir
|
|
||||||
out, _ := cmd.Output()
|
|
||||||
if len(strings.TrimSpace(string(out))) > 0 {
|
|
||||||
return fmt.Errorf("working tree dirty (--strict); commit your changes or drop --strict")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// `git stash create` only captures tracked content, so untracked
|
|
||||||
// files would be silently dropped from the archive. Warn loudly.
|
|
||||||
untrackedFilesWarning(repoDir, w)
|
|
||||||
}
|
}
|
||||||
|
_, _ = fmt.Fprintln(w, "warning: working tree differs from HEAD; the published .bnp is built from the working directory:")
|
||||||
submoduleWarning(repoDir, w)
|
for n := range strings.SplitSeq(dirty, "\n") {
|
||||||
|
_, _ = fmt.Fprintln(w, " "+n)
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintln(w, " (commit before publishing so the artifact matches the tagged source)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// submoduleWarning writes a warning to w if repoDir contains submodules.
|
|
||||||
// `git archive` doesn't recurse into them so they ship as empty directories;
|
|
||||||
// detection is via .gitmodules so it fires even for uninitialised submodules.
|
|
||||||
func submoduleWarning(repoDir string, w io.Writer) {
|
|
||||||
paths := submodulePaths(repoDir)
|
|
||||||
if len(paths) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = fmt.Fprintln(w, "warning: this repo has submodules; git archive will ship them as empty directories:")
|
|
||||||
for _, p := range paths {
|
|
||||||
_, _ = fmt.Fprintln(w, " "+p)
|
|
||||||
}
|
|
||||||
_, _ = fmt.Fprintln(w, " (vendor the contents or pack them separately if the plugin depends on them)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// autoCommitPluginMod stages and commits plugin.mod with the given message,
|
// autoCommitPluginMod stages and commits plugin.mod with the given message,
|
||||||
// if plugin.mod differs from HEAD. No-op when there's nothing to commit.
|
// if plugin.mod differs from HEAD. No-op when there's nothing to commit.
|
||||||
//
|
//
|
||||||
|
|||||||
@ -252,36 +252,7 @@ func gitHeadSHA(t *testing.T, dir string) string {
|
|||||||
return strings.TrimSpace(string(out))
|
return strings.TrimSpace(string(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGitignoredTrackedWarning_FiresWhenTrackedFileMatchesGitignore(t *testing.T) {
|
func TestEmitPublishWarnings_CleanTreeIsSilent(t *testing.T) {
|
||||||
dir := t.TempDir()
|
|
||||||
runGit(t, dir, "init", "-q")
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "secret.env"), []byte("token=abc"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
runGit(t, dir, "add", "secret.env")
|
|
||||||
runGit(t, dir, "commit", "-qm", "init")
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.env\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
runGit(t, dir, "add", ".gitignore")
|
|
||||||
runGit(t, dir, "commit", "-qm", "ignore")
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
gitignoredTrackedWarning(dir, &buf)
|
|
||||||
|
|
||||||
out := buf.String()
|
|
||||||
if !strings.Contains(out, "secret.env") {
|
|
||||||
t.Errorf("warning should list secret.env, got: %q", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "git rm --cached") {
|
|
||||||
t.Errorf("warning should suggest `git rm --cached`, got: %q", out)
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(out, "\n") {
|
|
||||||
t.Errorf("warning should end with a newline (Fprintln), got: %q", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGitignoredTrackedWarning_NoopWhenNothingMatches(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
runGit(t, dir, "init", "-q")
|
runGit(t, dir, "init", "-q")
|
||||||
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
|
||||||
@ -290,150 +261,18 @@ func TestGitignoredTrackedWarning_NoopWhenNothingMatches(t *testing.T) {
|
|||||||
runGit(t, dir, "add", "README.md")
|
runGit(t, dir, "add", "README.md")
|
||||||
runGit(t, dir, "commit", "-qm", "init")
|
runGit(t, dir, "commit", "-qm", "init")
|
||||||
|
|
||||||
var buf bytes.Buffer
|
for _, allowDirty := range []bool{true, false} {
|
||||||
gitignoredTrackedWarning(dir, &buf)
|
var buf bytes.Buffer
|
||||||
if buf.Len() != 0 {
|
if err := emitPublishWarnings(dir, allowDirty, &buf); err != nil {
|
||||||
t.Errorf("expected empty output for clean repo, got: %q", buf.String())
|
t.Fatalf("emitPublishWarnings(allowDirty=%t): %v", allowDirty, err)
|
||||||
|
}
|
||||||
|
if buf.Len() != 0 {
|
||||||
|
t.Errorf("expected empty output for clean tree (allowDirty=%t), got: %q", allowDirty, buf.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUntrackedFilesWarning_FiresWithUntrackedFile(t *testing.T) {
|
func TestEmitPublishWarnings_DirtyTree(t *testing.T) {
|
||||||
dir := t.TempDir()
|
|
||||||
runGit(t, dir, "init", "-q")
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
runGit(t, dir, "add", "README.md")
|
|
||||||
runGit(t, dir, "commit", "-qm", "init")
|
|
||||||
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("scratch"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
untrackedFilesWarning(dir, &buf)
|
|
||||||
|
|
||||||
out := buf.String()
|
|
||||||
if !strings.Contains(out, "notes.txt") {
|
|
||||||
t.Errorf("warning should list notes.txt, got: %q", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "git add") {
|
|
||||||
t.Errorf("warning should suggest `git add`, got: %q", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUntrackedFilesWarning_NoopWithNoUntracked(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
runGit(t, dir, "init", "-q")
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
runGit(t, dir, "add", "README.md")
|
|
||||||
runGit(t, dir, "commit", "-qm", "init")
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
untrackedFilesWarning(dir, &buf)
|
|
||||||
if buf.Len() != 0 {
|
|
||||||
t.Errorf("expected empty output, got: %q", buf.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSubmoduleWarning_FiresWithGitmodules(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
runGit(t, dir, "init", "-q")
|
|
||||||
gitmodules := `[submodule "vendor/foo"]
|
|
||||||
path = vendor/foo
|
|
||||||
url = https://example.com/foo.git
|
|
||||||
`
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, ".gitmodules"), []byte(gitmodules), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
submoduleWarning(dir, &buf)
|
|
||||||
|
|
||||||
out := buf.String()
|
|
||||||
if !strings.Contains(out, "vendor/foo") {
|
|
||||||
t.Errorf("warning should mention vendor/foo, got: %q", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "submodules") {
|
|
||||||
t.Errorf("warning should mention submodules, got: %q", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSubmoduleWarning_NoopWithoutGitmodules(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
runGit(t, dir, "init", "-q")
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
submoduleWarning(dir, &buf)
|
|
||||||
if buf.Len() != 0 {
|
|
||||||
t.Errorf("expected empty output, got: %q", buf.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEmitPublishWarnings_WarnsAboutGitignoreTracked(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
runGit(t, dir, "init", "-q")
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "secret.env"), []byte("token=abc"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
runGit(t, dir, "add", "secret.env")
|
|
||||||
runGit(t, dir, "commit", "-qm", "init")
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.env\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
runGit(t, dir, "add", ".gitignore")
|
|
||||||
runGit(t, dir, "commit", "-qm", "ignore")
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := emitPublishWarnings(dir, false, &buf); err != nil {
|
|
||||||
t.Fatalf("emitPublishWarnings: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := buf.String()
|
|
||||||
if !strings.Contains(out, "secret.env") {
|
|
||||||
t.Errorf("expected gitignored-tracked warning to mention secret.env, got: %q", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "match .gitignore") {
|
|
||||||
t.Errorf("expected gitignored-tracked warning fragment, got: %q", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEmitPublishWarnings_WarnsAboutSubmodules(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
runGit(t, dir, "init", "-q")
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
runGit(t, dir, "add", "README.md")
|
|
||||||
runGit(t, dir, "commit", "-qm", "init")
|
|
||||||
|
|
||||||
gitmodules := `[submodule "vendor/foo"]
|
|
||||||
path = vendor/foo
|
|
||||||
url = https://example.com/foo.git
|
|
||||||
`
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, ".gitmodules"), []byte(gitmodules), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
runGit(t, dir, "add", ".gitmodules")
|
|
||||||
runGit(t, dir, "commit", "-qm", "add submodule decl")
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := emitPublishWarnings(dir, false, &buf); err != nil {
|
|
||||||
t.Fatalf("emitPublishWarnings: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := buf.String()
|
|
||||||
if !strings.Contains(out, "vendor/foo") {
|
|
||||||
t.Errorf("expected submodule warning to mention vendor/foo, got: %q", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "submodules") {
|
|
||||||
t.Errorf("expected submodule warning fragment, got: %q", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEmitPublishWarnings_WarnsAboutUntrackedWithAllowDirty(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
runGit(t, dir, "init", "-q")
|
runGit(t, dir, "init", "-q")
|
||||||
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
|
||||||
@ -441,25 +280,32 @@ func TestEmitPublishWarnings_WarnsAboutUntrackedWithAllowDirty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
runGit(t, dir, "add", "README.md")
|
runGit(t, dir, "add", "README.md")
|
||||||
runGit(t, dir, "commit", "-qm", "init")
|
runGit(t, dir, "commit", "-qm", "init")
|
||||||
|
// One untracked and one modified-tracked path — both must surface.
|
||||||
if err := os.WriteFile(filepath.Join(dir, "scratch.txt"), []byte("notes"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, "scratch.txt"), []byte("notes"), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("changed"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
t.Run("allowDirty=true surfaces untracked warning", func(t *testing.T) {
|
t.Run("allowDirty=true emits drift warning and proceeds", func(t *testing.T) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := emitPublishWarnings(dir, true, &buf); err != nil {
|
if err := emitPublishWarnings(dir, true, &buf); err != nil {
|
||||||
t.Fatalf("emitPublishWarnings: %v", err)
|
t.Fatalf("emitPublishWarnings: %v", err)
|
||||||
}
|
}
|
||||||
out := buf.String()
|
out := buf.String()
|
||||||
if !strings.Contains(out, "scratch.txt") {
|
if !strings.Contains(out, "scratch.txt") {
|
||||||
t.Errorf("expected untracked-files warning to mention scratch.txt, got: %q", out)
|
t.Errorf("expected drift warning to mention untracked scratch.txt, got: %q", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "NOT be in the archive") {
|
if !strings.Contains(out, "README.md") {
|
||||||
t.Errorf("expected untracked-files warning fragment, got: %q", out)
|
t.Errorf("expected drift warning to mention modified README.md, got: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "built from the working directory") {
|
||||||
|
t.Errorf("expected drift warning fragment, got: %q", out)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("allowDirty=false aborts before untracked warning", func(t *testing.T) {
|
t.Run("allowDirty=false aborts", func(t *testing.T) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err := emitPublishWarnings(dir, false, &buf)
|
err := emitPublishWarnings(dir, false, &buf)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@ -471,8 +317,8 @@ func TestEmitPublishWarnings_WarnsAboutUntrackedWithAllowDirty(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), "--strict") {
|
if !strings.Contains(err.Error(), "--strict") {
|
||||||
t.Errorf("expected dirty-tree error to reference --strict, got: %v", err)
|
t.Errorf("expected dirty-tree error to reference --strict, got: %v", err)
|
||||||
}
|
}
|
||||||
if strings.Contains(buf.String(), "untracked files will NOT be in the archive") {
|
if buf.Len() != 0 {
|
||||||
t.Errorf("untracked-files warning should not fire on dirty-abort path, got: %q", buf.String())
|
t.Errorf("no warning should be written on the abort path, got: %q", buf.String())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,56 +0,0 @@
|
|||||||
package archive
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/klauspost/compress/zstd"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BuildSourceArchive captures the working tree as `tar.zst` bytes.
|
|
||||||
//
|
|
||||||
// When the working tree is clean it archives HEAD. When it's dirty
|
|
||||||
// (modified or staged tracked files), it archives a temporary stash
|
|
||||||
// object so the dirty state is what ships — callers that want
|
|
||||||
// HEAD-only behaviour should reject dirty trees before calling.
|
|
||||||
// Untracked files are never included regardless of state.
|
|
||||||
func BuildSourceArchive(repoDir string) ([]byte, error) {
|
|
||||||
stashCmd := exec.Command("git", "stash", "create")
|
|
||||||
stashCmd.Dir = repoDir
|
|
||||||
var stashOut, stashErr bytes.Buffer
|
|
||||||
stashCmd.Stdout = &stashOut
|
|
||||||
stashCmd.Stderr = &stashErr
|
|
||||||
if err := stashCmd.Run(); err != nil {
|
|
||||||
return nil, fmt.Errorf("git stash create: %v: %s", err, stashErr.String())
|
|
||||||
}
|
|
||||||
treeish := "HEAD"
|
|
||||||
if sha := strings.TrimSpace(stashOut.String()); sha != "" {
|
|
||||||
treeish = sha
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command("git", "archive", "--format=tar", treeish)
|
|
||||||
cmd.Dir = repoDir
|
|
||||||
var tarOut, stderr bytes.Buffer
|
|
||||||
cmd.Stdout = &tarOut
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return nil, fmt.Errorf("git archive: %v: %s", err, stderr.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var compressed bytes.Buffer
|
|
||||||
enc, err := zstd.NewWriter(&compressed, zstd.WithEncoderLevel(zstd.SpeedDefault))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if _, err := io.Copy(enc, &tarOut); err != nil {
|
|
||||||
_ = enc.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := enc.Close(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return compressed.Bytes(), nil
|
|
||||||
}
|
|
||||||
@ -1,208 +0,0 @@
|
|||||||
package archive
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/tar"
|
|
||||||
"bytes"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/klauspost/compress/zstd"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestBuildSourceArchive_RoundTrip(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
run := func(name string, args ...string) {
|
|
||||||
t.Helper()
|
|
||||||
cmd := exec.Command(name, args...)
|
|
||||||
cmd.Dir = dir
|
|
||||||
cmd.Env = append(os.Environ(),
|
|
||||||
"GIT_AUTHOR_NAME=t",
|
|
||||||
"GIT_AUTHOR_EMAIL=t@t",
|
|
||||||
"GIT_COMMITTER_NAME=t",
|
|
||||||
"GIT_COMMITTER_EMAIL=t@t",
|
|
||||||
)
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("%s %v: %v\n%s", name, args, err, out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
run("git", "init", "-q")
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
|
|
||||||
[]byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.0\"\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "ignored.log"), []byte("nope"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("ignored.log\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
run("git", "add", "plugin.mod", ".gitignore")
|
|
||||||
run("git", "commit", "-qm", "init")
|
|
||||||
|
|
||||||
zstdBytes, err := BuildSourceArchive(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildSourceArchive: %v", err)
|
|
||||||
}
|
|
||||||
if len(zstdBytes) == 0 {
|
|
||||||
t.Fatal("empty archive")
|
|
||||||
}
|
|
||||||
|
|
||||||
dec, err := zstd.NewReader(bytes.NewReader(zstdBytes))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer dec.Close()
|
|
||||||
tr := tar.NewReader(dec)
|
|
||||||
got := map[string]string{}
|
|
||||||
for {
|
|
||||||
hdr, err := tr.Next()
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
buf, err := io.ReadAll(tr)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got[hdr.Name] = string(buf)
|
|
||||||
}
|
|
||||||
if _, ok := got["plugin.mod"]; !ok {
|
|
||||||
t.Errorf("expected plugin.mod in archive, got %v", keys(got))
|
|
||||||
}
|
|
||||||
if _, ok := got["ignored.log"]; ok {
|
|
||||||
t.Errorf("ignored.log should not be in archive (gitignored + untracked)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func keys(m map[string]string) []string {
|
|
||||||
out := make([]string, 0, len(m))
|
|
||||||
for k := range m {
|
|
||||||
out = append(out, k)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildSourceArchive_DirtyTreeShipsWorkingCopy(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
runGitArchive(t, dir, "init", "-q")
|
|
||||||
modPath := filepath.Join(dir, "plugin.mod")
|
|
||||||
if err := os.WriteFile(modPath,
|
|
||||||
[]byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.0\"\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
runGitArchive(t, dir, "add", "plugin.mod")
|
|
||||||
runGitArchive(t, dir, "commit", "-qm", "init")
|
|
||||||
|
|
||||||
dirtyContents := []byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.1\"\n")
|
|
||||||
if err := os.WriteFile(modPath, dirtyContents, 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
zstdBytes, err := BuildSourceArchive(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildSourceArchive: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got := readArchive(t, zstdBytes)
|
|
||||||
contents, ok := got["plugin.mod"]
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected plugin.mod in archive, got %v", keys(got))
|
|
||||||
}
|
|
||||||
if !strings.Contains(contents, `version="0.1.1"`) {
|
|
||||||
t.Errorf("archived plugin.mod should have dirty version 0.1.1, got: %q", contents)
|
|
||||||
}
|
|
||||||
if strings.Contains(contents, `version="0.1.0"`) {
|
|
||||||
t.Errorf("archived plugin.mod should NOT have HEAD version 0.1.0, got: %q", contents)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Working tree should be unchanged after stash-create.
|
|
||||||
postContents, err := os.ReadFile(modPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if string(postContents) != string(dirtyContents) {
|
|
||||||
t.Errorf("working tree mutated after BuildSourceArchive\nwant: %q\ngot: %q",
|
|
||||||
string(dirtyContents), string(postContents))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildSourceArchive_DirtyTreeOmitsUntracked(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
runGitArchive(t, dir, "init", "-q")
|
|
||||||
modPath := filepath.Join(dir, "plugin.mod")
|
|
||||||
if err := os.WriteFile(modPath,
|
|
||||||
[]byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.0\"\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
runGitArchive(t, dir, "add", "plugin.mod")
|
|
||||||
runGitArchive(t, dir, "commit", "-qm", "init")
|
|
||||||
|
|
||||||
// Dirty the tracked file.
|
|
||||||
if err := os.WriteFile(modPath,
|
|
||||||
[]byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.1\"\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// Add an untracked file (no git add).
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "extra.txt"), []byte("not tracked"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
zstdBytes, err := BuildSourceArchive(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildSourceArchive: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got := readArchive(t, zstdBytes)
|
|
||||||
if _, ok := got["extra.txt"]; ok {
|
|
||||||
t.Errorf("untracked extra.txt should not be in archive, got %v", keys(got))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runGitArchive(t *testing.T, dir string, args ...string) {
|
|
||||||
t.Helper()
|
|
||||||
cmd := exec.Command("git", args...)
|
|
||||||
cmd.Dir = dir
|
|
||||||
cmd.Env = append(os.Environ(),
|
|
||||||
"GIT_AUTHOR_NAME=t",
|
|
||||||
"GIT_AUTHOR_EMAIL=t@t",
|
|
||||||
"GIT_COMMITTER_NAME=t",
|
|
||||||
"GIT_COMMITTER_EMAIL=t@t",
|
|
||||||
)
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func readArchive(t *testing.T, zstdBytes []byte) map[string]string {
|
|
||||||
t.Helper()
|
|
||||||
dec, err := zstd.NewReader(bytes.NewReader(zstdBytes))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer dec.Close()
|
|
||||||
tr := tar.NewReader(dec)
|
|
||||||
got := map[string]string{}
|
|
||||||
for {
|
|
||||||
hdr, err := tr.Next()
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
buf, err := io.ReadAll(tr)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got[hdr.Name] = string(buf)
|
|
||||||
}
|
|
||||||
return got
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user