From 348cca8471cef14f134c2ac8a38a7172884a17a0 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 25 Oct 2026 14:00:07 -0400 Subject: [PATCH 1/4] fix(ci): unbound evidence reports freshness unknown, never current (D-1123) --- free/ci/internal/run/evidence.go | 2 + free/ci/internal/run/status_mapping_test.go | 43 +++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/free/ci/internal/run/evidence.go b/free/ci/internal/run/evidence.go index d59dcc259..25ade2d65 100644 --- a/free/ci/internal/run/evidence.go +++ b/free/ci/internal/run/evidence.go @@ -96,6 +96,8 @@ func buildEvidence(in evidenceInput) (model.Evidence, string, error) { if in.Dirty || in.Revision == "" { reason := model.BindingReason("dirty") e.WorktreeState, e.Binding = "dirty", model.Binding{State: "unbound", Reason: &reason} + why := "the worktree is not the named commit" + e.Freshness = model.Freshness{State: "unknown", Reason: &why} // an unbound document is never current (schema) } for _, o := range in.Jobs { c := model.Check{ID: firstNonEmpty(o.Check, o.ID), JobID: o.ID, Kind: string(o.Kind), Tool: o.Tool, Required: o.Required, diff --git a/free/ci/internal/run/status_mapping_test.go b/free/ci/internal/run/status_mapping_test.go index 022513368..91193bf55 100644 --- a/free/ci/internal/run/status_mapping_test.go +++ b/free/ci/internal/run/status_mapping_test.go @@ -10,6 +10,7 @@ package run // coordinator. import ( + "encoding/json" "strings" "testing" "time" @@ -175,3 +176,45 @@ func TestPrepareFailureReportsThroughTheSentinel(t *testing.T) { }) } } + +// An unbound document (dirty tree, or no commit) is never current: the schema +// forbids binding unbound + freshness current. +func TestUnboundEvidenceIsNeverCurrent(t *testing.T) { + t.Parallel() + schemas, err := model.Schemas() + if err != nil { + t.Fatal(err) + } + start := time.Unix(0, 0) + jobs := []JobOutcome{{ID: "a", Required: true, Substantive: true, State: StatePassed}} + for name, tc := range map[string]struct { + in evidenceInput + fresh model.FreshnessState + }{ + "dirty": {evidenceInput{Revision: strings.Repeat("a", 40), Dirty: true}, "unknown"}, + "no commit": {evidenceInput{}, "unknown"}, + "clean": {evidenceInput{Revision: strings.Repeat("a", 40)}, "current"}, + } { + in := tc.in + in.Jobs, in.Start, in.End = jobs, start, start + in.Trigger, in.Pipeline = "local", model.PipelineRef{ID: "default", Name: "default", Kind: "default"} + in.Selection = model.Selection{Mode: "full", Reason: "selection.no_base"} + ev, _, err := buildEvidence(in) + if err != nil { + t.Fatal(err) + } + if ev.Freshness.State != tc.fresh { + t.Errorf("%s: freshness %q, want %q", name, ev.Freshness.State, tc.fresh) + } + doc, err := json.Marshal(ev) + if err != nil { + t.Fatal(err) + } + if in.Revision == "" { // an empty revision fails the schema pattern on its own; the freshness rule is what is pinned here + continue + } + if err := model.ValidateJSON(schemas["ci-evidence.v1.schema.json"], doc); err != nil { + t.Errorf("%s: %v", name, err) + } + } +} From da0b6382cdbef43ef2c62c94e57b0019d6169efe Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 25 Oct 2026 14:00:07 -0400 Subject: [PATCH 2/4] fix(ci): say when a fetched protected-branch tip cannot be read (D-1126) --- free/ci/internal/policy/branch.go | 4 +++- free/ci/internal/policy/hardening_test.go | 24 +++++++++++++++++++++++ free/ci/internal/policy/resolve.go | 6 +++++- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/free/ci/internal/policy/branch.go b/free/ci/internal/policy/branch.go index 77e4ff8d3..1d2ee98ce 100644 --- a/free/ci/internal/policy/branch.go +++ b/free/ci/internal/policy/branch.go @@ -46,6 +46,8 @@ type Branch struct { Fetch FetchState // Assumed: origin's default branch could not be discovered, so Name is the spec fallback main. Assumed bool + // Fetched: the fetch itself succeeded. With an empty Tip it means the fetched tip could not be read. + Fetched bool Policy map[string]json.RawMessage // the tip's flat policy; nil when it has none } @@ -130,7 +132,7 @@ func readBranch(ctx context.Context, run Runner, root string) (Branch, error) { out, ok := g.git(ctx, "rev-parse", "--verify", "--quiet", ref+"^{commit}") tip := lastLine(out) if !ok || !fullSHA.MatchString(tip) { - b.Fetch = FetchNone + b.Fetch, b.Fetched = FetchNone, fetched // fetched but unreadable is told apart from never fetched return b, nil } b.Tip = tip diff --git a/free/ci/internal/policy/hardening_test.go b/free/ci/internal/policy/hardening_test.go index dfb98070e..61d9d5569 100644 --- a/free/ci/internal/policy/hardening_test.go +++ b/free/ci/internal/policy/hardening_test.go @@ -9,6 +9,7 @@ import ( "context" "os" "path/filepath" + "slices" "strings" "testing" ) @@ -214,3 +215,26 @@ func TestMalformedStoredValueRejected(t *testing.T) { } } } + +// The fetch worked but the tip cannot be read: the run goes on without the +// protected-branch scope and says that the fetch succeeded, so the operator +// looks at the ref and not at the network. +func TestFetchedButUnreadableTipIsReported(t *testing.T) { + f := newFixture(t) + f.runner = func(ctx context.Context, argv ...string) (string, int, error) { + if slices.Contains(argv, "rev-parse") { + return "", 128, nil + } + return testRunner(ctx, argv...) + } + got := resolveOwner(t, f, f.work, "p", nil) + var reason string + for _, ig := range got.Ignored { + if ig.Key == "protected_branch" { + reason = ig.Reason + } + } + if got.Fetch != FetchNone || !strings.HasPrefix(reason, "E672") || !strings.Contains(reason, "was fetched but its tip could not be read") { + t.Fatalf("fetch %s ignored %v", got.Fetch, got.Ignored) + } +} diff --git a/free/ci/internal/policy/resolve.go b/free/ci/internal/policy/resolve.go index be0fbe29b..79f0ee492 100644 --- a/free/ci/internal/policy/resolve.go +++ b/free/ci/internal/policy/resolve.go @@ -107,7 +107,11 @@ func (r *Resolver) Resolve(ctx context.Context, repo Repo, rev Revision) (Resolv ignored = append(ignored, Ignored{Key: "default_branch", Scope: SourceProtectedBranch, Reason: "E672 origin's default branch could not be discovered; main is assumed"}) } if branch.Name != "" && branch.Tip == "" { - ignored = append(ignored, Ignored{Key: "protected_branch", Scope: SourceProtectedBranch, Reason: "E672 origin exists but origin/" + branch.Name + " could not be fetched or read; no protected-branch scope applies"}) + why := "could not be fetched or read" + if branch.Fetched { + why = "was fetched but its tip could not be read" + } + ignored = append(ignored, Ignored{Key: "protected_branch", Scope: SourceProtectedBranch, Reason: "E672 origin exists but origin/" + branch.Name + " " + why + "; no protected-branch scope applies"}) } var tipPolicy map[string]json.RawMessage if branch.Tip != "" { From e02d3b8cdd81a8fb6ca81f14b699f6a82e32379a Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 25 Oct 2026 14:00:08 -0400 Subject: [PATCH 3/4] fix(ci): policy hook hands branch and fetch diagnostics to the run notes (D-1125) --- free/ci/internal/run/finish.go | 1 + free/ci/internal/run/policy.go | 13 +++++++ free/ci/internal/run/policy_hook.go | 5 +++ free/ci/internal/run/runscope_test.go | 49 +++++++++++++++++++++++++++ 4 files changed, 68 insertions(+) diff --git a/free/ci/internal/run/finish.go b/free/ci/internal/run/finish.go index cc8bae5f6..5582eea95 100644 --- a/free/ci/internal/run/finish.go +++ b/free/ci/internal/run/finish.go @@ -159,6 +159,7 @@ func (c *Coordinator) conclude(f finishIn) Result { Trigger: trig, Jobs: f.Jobs, Start: f.Start, End: time.Now()}) notes := append([]string(nil), f.Post.Notes...) notes = append(notes, ignoredRunNotes(f.Policy.IgnoredRun)...) + notes = append(notes, policyNotes(f.Policy.Notes)...) if err == nil && f.Hooks.Evidence != nil { facts := EvidenceFacts{Root: c.Opts.Root, RunID: f.RunID, Rev: f.Rev, Store: f.St, Git: c.capture(), Jobs: f.Jobs, Job: c.Opts.Job} var done model.Evidence diff --git a/free/ci/internal/run/policy.go b/free/ci/internal/run/policy.go index d899ddd9f..722968760 100644 --- a/free/ci/internal/run/policy.go +++ b/free/ci/internal/run/policy.go @@ -21,6 +21,10 @@ type PolicyResult struct { // IgnoredRun lists the per-run values the policy did not take, one line each // ("key: reason"); a run reports them, it never drops them silently (P7-AUTO-29). IgnoredRun []string + // Notes are the other things the resolution reports (values refused from the + // protected branch, the worktree or the pin; a branch tip that is stale or + // unreadable), one line each; a run lists them, it never drops them. + Notes []string SecretsInfo } @@ -56,6 +60,15 @@ func ResolvePolicy(ctx context.Context, root, pipeline string) (PolicyResult, er return r.Resolve(ctx, PolicyRequest{Root: root, Pipeline: pipeline}) } +// policyNotes turns the resolution diagnostics into run notes. +func policyNotes(lines []string) []string { + var out []string + for _, line := range lines { + out = append(out, "policy: "+line) + } + return out +} + // ignoredRunNotes turns the per-run policy values the policy refused into run // notes ("key: reason" lines). func ignoredRunNotes(lines []string) []string { diff --git a/free/ci/internal/run/policy_hook.go b/free/ci/internal/run/policy_hook.go index 56fdf44fd..5905361b0 100644 --- a/free/ci/internal/run/policy_hook.go +++ b/free/ci/internal/run/policy_hook.go @@ -58,8 +58,13 @@ func (h PolicyHook) Resolve(ctx context.Context, r PolicyRequest) (PolicyResult, for _, ig := range got.Ignored { if ig.Scope == policy.SourceRun { out.IgnoredRun = append(out.IgnoredRun, ig.Key+": "+ig.Reason) + } else { + out.Notes = append(out.Notes, ig.Key+": "+ig.Reason) } } + if got.Fetch == policy.FetchStale { + out.Notes = append(out.Notes, "protected-branch tip is stale: the fetch of origin failed, the last fetched tip applies") + } if v, ok := got.Values["trust.secrets"]; ok { out.SecretClasses, out.HasSecretClasses = secrets.AllowlistOfValue(v) } diff --git a/free/ci/internal/run/runscope_test.go b/free/ci/internal/run/runscope_test.go index 32398126a..e318fb79f 100644 --- a/free/ci/internal/run/runscope_test.go +++ b/free/ci/internal/run/runscope_test.go @@ -184,3 +184,52 @@ func TestPolicyHookReadsTheRequestStore(t *testing.T) { t.Fatalf("the project pin of the request store was not read: ignored %v", got.IgnoredRun) } } + +// TestPolicyHookReportsBranchDiagnostics: an origin that cannot be read leaves no +// protected-branch scope; the hook hands that to the run as a note, it does not +// return the digest alone. +func TestPolicyHookReportsBranchDiagnostics(t *testing.T) { + ctx := context.Background() + root, _, db := runScopeRepo(t) + if out, err := exec.Command("git", "-C", root, "remote", "add", "origin", filepath.Join(t.TempDir(), "gone.git")).CombinedOutput(); err != nil { + t.Fatalf("git remote add: %s %v", out, err) + } + got, err := PolicyHook{}.Resolve(ctx, PolicyRequest{Root: root, Store: db}) + if err != nil { + t.Fatal(err) + } + if len(got.IgnoredRun) != 0 || len(got.Notes) == 0 || !strings.Contains(strings.Join(got.Notes, "\n"), "protected_branch: E672") { + t.Fatalf("the unreadable origin went unreported: run %v notes %v", got.IgnoredRun, got.Notes) + } +} + +func TestPolicyNotes(t *testing.T) { + got := policyNotes([]string{"protected_branch: E672 origin exists"}) + if len(got) != 1 || got[0] != "policy: protected_branch: E672 origin exists" { + t.Errorf("notes = %q", got) + } +} + +type notedPolicy struct{ lines []string } + +func (p notedPolicy) Resolve(ctx context.Context, r PolicyRequest) (PolicyResult, error) { + got, err := defaultResolver{}.Resolve(ctx, r) + got.Notes = p.lines + return got, err +} + +// TestPolicyNotesReachSummary: the resolution diagnostics are notes of the finished run. +func TestPolicyNotesReachSummary(t *testing.T) { + r := passRig(t) + r.C.Hooks.Policy = notedPolicy{[]string{"protected_branch: E672 origin exists"}} + res := r.run(t) + var got []string + for _, n := range res.Summary.Notes { + if strings.HasPrefix(n, "policy: protected_branch") { + got = append(got, n) + } + } + if len(got) != 1 { + t.Fatalf("notes = %q", res.Summary.Notes) + } +} From 2ddddd7f0f5da18c0bab309c3881c3febcd8f92b Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 25 Oct 2026 14:00:08 -0400 Subject: [PATCH 4/4] fix(file-processing,media-processing): pnpm install path fails loudly, pnpm 9/10/11 matrix (D-0623) --- .github/workflows/build.yml | 47 +++++++++++++++++++ free/file-processing/QUICKSTART.md | 7 ++- free/file-processing/README.md | 6 +-- free/file-processing/install.sh | 40 ++++++++++++---- free/file-processing/tests/install-ts.test.sh | 37 +++++++++++++++ free/file-processing/ts/pnpm-workspace.yaml | 2 +- free/media-processing/README.md | 6 ++- free/media-processing/ts/pnpm-workspace.yaml | 2 +- 8 files changed, 128 insertions(+), 19 deletions(-) create mode 100755 free/file-processing/tests/install-ts.test.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 45e2095f4..ff7a03262 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -98,6 +98,53 @@ jobs: # Expiry: 2026-12-01 — remove once all plugins have pnpm build configured and passing. continue-on-error: true + # D-0623: free/file-processing/ts and free/media-processing/ts link + # @nself/plugin-utils from ../../../shared (link:), which only pnpm can install. + # Every supported pnpm major installs, typechecks and builds them with + # shared/dist absent: the typecheck and build scripts compile shared first. + ts-pnpm-matrix: + name: pnpm ${{ matrix.pnpm }} / ${{ matrix.plugin }} + runs-on: ubuntu-latest + timeout-minutes: 15 + + strategy: + fail-fast: false + matrix: + pnpm: [9, 10, 11] + plugin: [file-processing, media-processing] + + steps: + - name: Checkout + uses: actions/checkout@v7.0.1 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + version: ${{ matrix.pnpm }} + + - name: Install shared dependencies (shared/dist stays absent) + working-directory: shared + run: | + pnpm install --frozen-lockfile + test ! -e dist + + - name: Install dependencies + working-directory: free/${{ matrix.plugin }}/ts + run: pnpm install --frozen-lockfile + + - name: Type check + working-directory: free/${{ matrix.plugin }}/ts + run: pnpm run typecheck + + - name: Build + working-directory: free/${{ matrix.plugin }}/ts + run: pnpm run build + lint-typescript: name: TypeScript Lint runs-on: ubuntu-latest diff --git a/free/file-processing/QUICKSTART.md b/free/file-processing/QUICKSTART.md index 60dc8b3be..bedba64ef 100644 --- a/free/file-processing/QUICKSTART.md +++ b/free/file-processing/QUICKSTART.md @@ -55,10 +55,13 @@ REDIS_URL=redis://localhost:6379 ### 3. Build TypeScript ```bash -npm install -npm run build +cd ts +pnpm install --frozen-lockfile +pnpm run build ``` +`ts/` links `@nself/plugin-utils` from `../../../shared` (a `link:` dependency), which npm cannot install (`EUNSUPPORTEDPROTOCOL`). Use pnpm 9, 10 or 11. The build compiles `shared/` first, so install its dependencies once: `(cd ../../../shared && pnpm install --frozen-lockfile)`. + ## Usage ### Start Services diff --git a/free/file-processing/README.md b/free/file-processing/README.md index 1fc32cfa5..2917c82ec 100644 --- a/free/file-processing/README.md +++ b/free/file-processing/README.md @@ -31,7 +31,7 @@ The plugin requires: - **Node.js 18+** (for TypeScript server) - **PostgreSQL** (for job storage) - **Redis** (for queue management) -- **Sharp** (for image processing - installed via npm) +- **Sharp** (for image processing - installed via pnpm) - **ffmpeg** (for video thumbnails - optional) - macOS: `brew install ffmpeg` - Linux: `apt-get install ffmpeg` @@ -214,8 +214,8 @@ nself plugin file-processing worker # Or run both in development cd plugins/file-processing/ts -npm run dev # Server -npm run worker # Worker (in another terminal) +pnpm run dev # Server +pnpm run worker # Worker (in another terminal) ``` ### CLI Commands diff --git a/free/file-processing/install.sh b/free/file-processing/install.sh index 036f3c891..39e9ebb6d 100755 --- a/free/file-processing/install.sh +++ b/free/file-processing/install.sh @@ -16,6 +16,31 @@ source "${SHARED_DIR}/schema-sync.sh" # Installation # ============================================================================= +# ts/ depends on ../../../shared through a link: specifier, which npm cannot +# install (EUNSUPPORTEDPROTOCOL); pnpm 9, 10 and 11 can. A failed install or +# build fails the installer: it never reports success over a missing build. +install_typescript_dependencies() { + [[ -d "${PLUGIN_DIR}/ts" ]] || return 0 + if ! command -v pnpm >/dev/null 2>&1; then + plugin_error "pnpm is required to install the TypeScript dependencies (npm cannot install the link: dependency). See https://pnpm.io/installation" + return 1 + fi + plugin_info "Installing TypeScript dependencies..." + # The ts build compiles shared/ first, which needs shared's own dependencies. + if ! (cd "$SHARED_DIR" && pnpm install --frozen-lockfile); then + plugin_error "Failed to install the shared dependencies" + return 1 + fi + if ! (cd "${PLUGIN_DIR}/ts" && pnpm install --frozen-lockfile); then + plugin_error "Failed to install the TypeScript dependencies" + return 1 + fi + if ! (cd "${PLUGIN_DIR}/ts" && pnpm run build); then + plugin_error "Failed to build the TypeScript sources" + return 1 + fi +} + install_file_processing_plugin() { plugin_info "Installing File Processing plugin..." @@ -95,14 +120,7 @@ install_file_processing_plugin() { mkdir -p "${HOME}/.nself/logs/plugins/file-processing" mkdir -p "${HOME}/.nself/temp/plugins/file-processing" - # Install TypeScript dependencies - if [[ -d "${PLUGIN_DIR}/ts" ]]; then - plugin_info "Installing TypeScript dependencies..." - cd "${PLUGIN_DIR}/ts" - npm install --silent 2>/dev/null || plugin_warn "Failed to install npm dependencies" - npm run build --silent 2>/dev/null || plugin_warn "Failed to build TypeScript" - cd - > /dev/null - fi + install_typescript_dependencies plugin_success "File Processing plugin installed successfully!" @@ -118,5 +136,7 @@ install_file_processing_plugin() { printf "\n" } -# Run installation -install_file_processing_plugin +# Run installation (only when executed; the test sources this file) +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + install_file_processing_plugin +fi diff --git a/free/file-processing/tests/install-ts.test.sh b/free/file-processing/tests/install-ts.test.sh new file mode 100755 index 000000000..14dcf5270 --- /dev/null +++ b/free/file-processing/tests/install-ts.test.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# D-0623: install.sh must fail when the TypeScript install or build fails, and +# must use pnpm (npm cannot install the link: dependency on shared/). +# Runs install_typescript_dependencies against a stub pnpm; no network. +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STUBS="$(mktemp -d "${TMPDIR:-/tmp}/fp-install-test.XXXXXX")" +trap 'rm -rf "$STUBS"' EXIT +FAILED=0 + +# shellcheck source=../install.sh +source "$HERE/../install.sh" +set +e # install.sh sets -e; the cases below expect non-zero returns + +# stub : a pnpm that logs its arguments and fails on "" ("" = never). +stub() { + printf '#!/bin/bash\necho "$PWD :: $*" >> "%s/calls"\n[ "$1 $2" = "%s" ] && exit 1\nexit 0\n' "$STUBS" "$1" > "$STUBS/pnpm" + chmod +x "$STUBS/pnpm" + : > "$STUBS/calls" +} + +check() { # check + if [ "$2" != "$3" ]; then echo "FAIL $1: rc $3, want $2"; FAILED=1; else echo "ok $1"; fi +} + +run() { (PATH="$STUBS:/usr/bin:/bin"; install_typescript_dependencies) >/dev/null 2>&1; } + +stub ""; run; check "all steps pass" 0 $? +grep -q ":: run build" "$STUBS/calls" || { echo "FAIL build was not run"; FAILED=1; } + +stub "install --frozen-lockfile"; run; check "install failure fails the installer" 1 $? +stub "run build"; run; check "build failure fails the installer" 1 $? + +rm -f "$STUBS/pnpm"; run; check "missing pnpm fails the installer" 1 $? + +exit "$FAILED" diff --git a/free/file-processing/ts/pnpm-workspace.yaml b/free/file-processing/ts/pnpm-workspace.yaml index ffac52c9f..bbd861251 100644 --- a/free/file-processing/ts/pnpm-workspace.yaml +++ b/free/file-processing/ts/pnpm-workspace.yaml @@ -1,6 +1,6 @@ # pnpm >= 10 reads install settings from this file; pnpm >= 11 reads them # ONLY from here (package.json "pnpm" is ignored). pnpm 9, which the -# GitHub nself-ci workflow and the CI hosts run, reads package.json, so +# Build Plugins workflow's main job and the CI hosts run, reads package.json, so # the overrides below are kept identical to package.json pnpm.overrides. # packages: ['.'] keeps this directory its own one-package workspace. packages: diff --git a/free/media-processing/README.md b/free/media-processing/README.md index 4992bb12a..c308e368b 100644 --- a/free/media-processing/README.md +++ b/free/media-processing/README.md @@ -20,10 +20,12 @@ FFmpeg-based media encoding and processing with HLS streaming support for nself. ```bash cd plugins/media-processing/ts -npm install -npm run build +pnpm install --frozen-lockfile +pnpm run build ``` +`ts/` links `@nself/plugin-utils` from `../../../shared` (a `link:` dependency), which npm cannot install (`EUNSUPPORTEDPROTOCOL`). Use pnpm 9, 10 or 11. The build compiles `shared/` first, so install its dependencies once: `(cd ../../../shared && pnpm install --frozen-lockfile)`. + ## Configuration Copy `.env.example` to `.env` and configure: diff --git a/free/media-processing/ts/pnpm-workspace.yaml b/free/media-processing/ts/pnpm-workspace.yaml index 27d058c01..ca7dd23a6 100644 --- a/free/media-processing/ts/pnpm-workspace.yaml +++ b/free/media-processing/ts/pnpm-workspace.yaml @@ -1,6 +1,6 @@ # pnpm >= 10 reads install settings from this file; pnpm >= 11 reads them # ONLY from here (package.json "pnpm" is ignored). pnpm 9, which the -# GitHub nself-ci workflow and the CI hosts run, reads package.json, so +# Build Plugins workflow's main job and the CI hosts run, reads package.json, so # the overrides below are kept identical to package.json pnpm.overrides. # packages: ['.'] keeps this directory its own one-package workspace. packages: