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
47 changes: 47 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion free/ci/internal/policy/branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions free/ci/internal/policy/hardening_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"context"
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
Expand Down Expand Up @@ -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)
}
}
6 changes: 5 additions & 1 deletion free/ci/internal/policy/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down
2 changes: 2 additions & 0 deletions free/ci/internal/run/evidence.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions free/ci/internal/run/finish.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions free/ci/internal/run/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions free/ci/internal/run/policy_hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
49 changes: 49 additions & 0 deletions free/ci/internal/run/runscope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
43 changes: 43 additions & 0 deletions free/ci/internal/run/status_mapping_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ package run
// coordinator.

import (
"encoding/json"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -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)
}
}
}
7 changes: 5 additions & 2 deletions free/file-processing/QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions free/file-processing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
40 changes: 30 additions & 10 deletions free/file-processing/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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..."

Expand Down Expand Up @@ -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!"

Expand All @@ -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
Loading
Loading