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
Binary file removed .DS_Store
Binary file not shown.
15 changes: 15 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,21 @@ jobs:
with:
go-version: '1.23'
cache: true

# Catch the easy mistake of pushing a tag without bumping VERSION.
# docops update-check fetches VERSION via raw.githubusercontent.com,
# so a mismatch silently breaks "is upgrade available" for everyone
# who already has the previous release. Fail loud here instead.
- name: Verify VERSION matches tag
run: |
tag_version="${GITHUB_REF#refs/tags/v}"
file_version=$(tr -d '[:space:]' < VERSION)
if [ "$tag_version" != "$file_version" ]; then
echo "::error::tag $GITHUB_REF (=$tag_version) does not match VERSION file (=$file_version)"
echo "::error::run 'make release VERSION=$tag_version' on main to do this correctly"
exit 1
fi

- uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
Expand Down
44 changes: 43 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ LDFLAGS := -s -w \
-X $(PKG)/internal/version.Commit=$(COMMIT) \
-X $(PKG)/internal/version.Date=$(DATE)

.PHONY: build install test lint clean tidy release-snapshot
.PHONY: build install test lint clean tidy release-snapshot release

build:
@mkdir -p bin
Expand All @@ -31,3 +31,45 @@ clean:

release-snapshot:
goreleaser release --snapshot --clean

# make release VERSION=X.Y.Z
# Bumps the VERSION file (read by docops update-check via raw.githubusercontent.com),
# commits the bump, creates an annotated v$VERSION tag, and pushes both.
# The tag push triggers .github/workflows/release.yml → goreleaser builds and
# publishes the GitHub Release. Pass DRY_RUN=1 to print what would happen
# without writing or pushing anything.
release:
@if [ -z "$(VERSION)" ] || [ "$(VERSION)" = "dev" ]; then \
echo "usage: make release VERSION=X.Y.Z [DRY_RUN=1]"; exit 2; \
fi
@echo "$(VERSION)" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$$' || \
(echo "VERSION must match X.Y.Z (got $(VERSION))" && exit 2)
@if ! git diff-index --quiet HEAD --; then \
echo "tracked files have uncommitted changes; commit or stash first"; exit 2; \
fi
@branch=$$(git rev-parse --abbrev-ref HEAD); \
if [ "$$branch" != "main" ]; then \
echo "refusing to release from '$$branch' — switch to main first"; exit 2; \
fi
@if git rev-parse "v$(VERSION)" >/dev/null 2>&1; then \
echo "tag v$(VERSION) already exists locally"; exit 2; \
fi
@if git ls-remote --exit-code --tags origin "refs/tags/v$(VERSION)" >/dev/null 2>&1; then \
echo "tag v$(VERSION) already exists on origin"; exit 2; \
fi
@if [ -n "$(DRY_RUN)" ]; then \
echo "[dry-run] would write '$(VERSION)' to VERSION"; \
echo "[dry-run] would commit: chore: release v$(VERSION)"; \
echo "[dry-run] would tag: v$(VERSION)"; \
echo "[dry-run] would push: main + v$(VERSION) to origin"; \
exit 0; \
fi
echo "$(VERSION)" > VERSION
git add VERSION
git commit -m "chore: release v$(VERSION)"
git tag -a "v$(VERSION)" -m "v$(VERSION)"
git push origin main
git push origin "v$(VERSION)"
@echo
@echo "v$(VERSION) tagged and pushed. Watch the release workflow:"
@echo " gh run watch"
36 changes: 31 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,23 @@ A GHCR image lands in a follow-up release. Until then, use Homebrew, Scoop, or d

Per-platform packages (`@docops/cli-darwin-arm64`, `@docops/cli-linux-x64`, ...) will publish alongside a future release. `npm i -g @docops/cli` resolves the matching native binary via `optionalDependencies` — no postinstall network fetch. See ADR-0012 for distribution rationale.

### Upgrading an existing project

After `brew upgrade docops` (or your package manager equivalent), pull the
new binary's shipped templates into your project without clobbering
`docops.yaml` or your pre-commit hook:

```sh
brew upgrade docops # or scoop update docops, etc.
docops upgrade # syncs skills, schemas, AGENTS.md block
docops upgrade --dry-run # preview first if you prefer
```

`docops upgrade` only touches DocOps-owned scaffolding. To also rewrite
`docops.yaml` or reinstall the pre-commit hook, opt in with `--config`
or `--hook`. Run `docops update-check` (or wait for `docops upgrade` to
warn you on its own) to learn when a new release is available.

## Smoke test

```sh
Expand All @@ -43,7 +60,7 @@ docops --help
From the root of any git repo (empty or existing):

```sh
docops init # scaffolds docs/, docops.yaml, schemas, skills, pre-commit hook
docops init # scaffolds docs/, docops.yaml, schemas, skills, pre-commit hook, AGENTS.md + CLAUDE.md
docops new ctx "Vision" --type brief --no-open # first CTX
docops new adr "Pick a database" # first decision
docops new task "Wire up SQLite" --requires ADR-0001
Expand Down Expand Up @@ -90,19 +107,28 @@ make lint # go vet ./...

### Release

Tag a commit with `vX.Y.Z`; the `Release` workflow runs goreleaser, which builds the matrix, attaches archives + checksums to the GitHub Release, and updates the brew/scoop stubs (once those repos exist).
From a clean `main`:

```sh
git tag v0.1.0
git push origin v0.1.0
make release VERSION=0.1.2
```

For a dry run:
That bumps the `VERSION` file (which `docops update-check` reads via raw.githubusercontent.com), commits the bump, creates an annotated `v0.1.2` tag, and pushes both to `origin`. The tag triggers `.github/workflows/release.yml`, which verifies that the tag matches the `VERSION` file and then runs goreleaser to build the matrix, attach archives + checksums to the GitHub Release, and update the brew/scoop stubs (once those tap repos exist).

Preview without writing:

```sh
make release VERSION=0.1.2 DRY_RUN=1
```

Local snapshot build (no tag, no push):

```sh
make release-snapshot
```

If you tag manually with `git tag` and forget to bump `VERSION`, the release workflow fails fast with a clear error pointing you at `make release`.

## License

MIT — see LICENSE.
1 change: 1 addition & 0 deletions VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.1.1
69 changes: 69 additions & 0 deletions cmd/docops/bootstrap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package main

import (
"errors"
"fmt"
"os"
"time"

"github.com/logicwind/docops/internal/config"
"github.com/logicwind/docops/internal/index"
"github.com/logicwind/docops/internal/loader"
"github.com/logicwind/docops/internal/validator"
)

// bootstrapIndex is the shared bootstrap sequence for read-only commands:
// find config, load docs, validate, build in-memory index.
// Returns the index, the project root, and an exit code (0 = success, 2 = error).
// On any failure it prints a prefixed error to stderr.
func bootstrapIndex(cmd string) (*index.Index, string, int) {
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "docops %s: %v\n", cmd, err)
return nil, "", 2
}
cfg, root, err := config.FindAndLoad(cwd)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
fmt.Fprintf(os.Stderr, "docops %s: no docops.yaml found — run `docops init` first\n", cmd)
return nil, "", 2
}
fmt.Fprintf(os.Stderr, "docops %s: %v\n", cmd, err)
return nil, "", 2
}
set, err := loader.Load(root, cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "docops %s: %v\n", cmd, err)
return nil, "", 2
}
report := validator.Validate(set, cfg)
if !report.OK() {
fmt.Fprintf(os.Stderr, "docops %s: refusing: %d validation error(s); run 'docops validate'\n", cmd, len(report.Errors))
return nil, "", 2
}
idx, err := index.Build(set, cfg, root, time.Now())
if err != nil {
fmt.Fprintf(os.Stderr, "docops %s: build index: %v\n", cmd, err)
return nil, "", 2
}
return idx, root, 0
}

// indexLookup finds a doc by ID.
func indexLookup(idx *index.Index, id string) (index.IndexedDoc, bool) {
for _, doc := range idx.Docs {
if doc.ID == id {
return doc, true
}
}
return index.IndexedDoc{}, false
}

// indexByID builds a map from ID → IndexedDoc for O(1) lookup.
func indexByID(idx *index.Index) map[string]index.IndexedDoc {
m := make(map[string]index.IndexedDoc, len(idx.Docs))
for _, doc := range idx.Docs {
m[doc.ID] = doc
}
return m
}
145 changes: 145 additions & 0 deletions cmd/docops/cmd_get.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package main

import (
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"strings"

"github.com/logicwind/docops/internal/index"
)

// cmdGet implements `docops get <id> [--json]`.
// Exit codes:
//
// 0 found
// 1 not found
// 2 bootstrap error or bad usage
func cmdGet(args []string) int {
fs := flag.NewFlagSet("get", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
asJSON := fs.Bool("json", false, "emit the full IndexedDoc as JSON")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, "usage: docops get <id> [--json]")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
if fs.NArg() != 1 {
fmt.Fprintln(os.Stderr, "docops get: requires exactly one argument <id>")
fs.Usage()
return 2
}
id := fs.Arg(0)

idx, _, code := bootstrapIndex("get")
if code != 0 {
return code
}

doc, ok := indexLookup(idx, id)
if !ok {
fmt.Fprintf(os.Stderr, "docops get: %q not found\n", id)
return 1
}

if *asJSON {
b, err := json.MarshalIndent(doc, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "docops get: encode: %v\n", err)
return 2
}
fmt.Println(string(b))
return 0
}

fmt.Print(humanGet(doc))
return 0
}

func humanGet(doc index.IndexedDoc) string {
var sb strings.Builder
fmt.Fprintf(&sb, "%s %s\n", doc.ID, doc.CTXTitle)

field := func(k, v string) {
if v != "" {
fmt.Fprintf(&sb, " %-16s %s\n", k+":", v)
}
}

field("kind", doc.Kind)
switch doc.Kind {
case "ADR":
field("status", doc.ADRStatus)
field("coverage", doc.ADRCoverage)
field("date", doc.ADRDate)
if len(doc.ADRTags) > 0 {
field("tags", strings.Join(doc.ADRTags, ", "))
}
if len(doc.CTXSupersedes) > 0 {
field("supersedes", strings.Join(doc.CTXSupersedes, ", "))
}
if len(doc.ADRRelated) > 0 {
field("related", strings.Join(doc.ADRRelated, ", "))
}
field("implementation", doc.Implementation)
case "TP":
field("status", doc.TaskStatus)
field("priority", doc.TaskPriority)
field("assignee", doc.TaskAssignee)
if len(doc.TaskRequires) > 0 {
field("requires", strings.Join(doc.TaskRequires, ", "))
}
if len(doc.TaskDependsOn) > 0 {
field("depends_on", strings.Join(doc.TaskDependsOn, ", "))
}
case "CTX":
field("type", doc.CTXType)
if len(doc.CTXSupersedes) > 0 {
field("supersedes", strings.Join(doc.CTXSupersedes, ", "))
}
}

field("summary", doc.Summary)
lt := doc.LastTouched
if len(lt) >= 10 {
lt = lt[:10]
}
field("last_touched", lt)
fmt.Fprintf(&sb, " %-16s %d\n", "age_days:", doc.AgeDays)
fmt.Fprintf(&sb, " %-16s %d\n", "word_count:", doc.WordCount)
staleStr := "false"
if doc.Stale {
staleStr = "true"
}
field("stale", staleStr)

// Reverse edges.
if len(doc.SupersededBy) > 0 {
field("superseded_by", strings.Join(doc.SupersededBy, ", "))
}
if len(doc.ReferencedBy) > 0 {
parts := make([]string, len(doc.ReferencedBy))
for i, r := range doc.ReferencedBy {
parts[i] = fmt.Sprintf("%s (%s)", r.ID, r.Edge)
}
field("referenced_by", strings.Join(parts, ", "))
}
if len(doc.ActiveTasks) > 0 {
field("active_tasks", strings.Join(doc.ActiveTasks, ", "))
}
if len(doc.DerivedADRs) > 0 {
field("derived_adrs", strings.Join(doc.DerivedADRs, ", "))
}
if len(doc.Blocks) > 0 {
field("blocks", strings.Join(doc.Blocks, ", "))
}

return sb.String()
}
Loading
Loading