analyze: detect stray-apostrophe and unescaped-${VAR} Woodpecker footguns #12
@@ -29,6 +29,20 @@ are excluded from scans — this keeps vendored upstream mirrors' own CI test
|
|||||||
fixtures (e.g. the `woodpecker` repo's own `test-dag`/`test-when`/... corpus)
|
fixtures (e.g. the `woodpecker` repo's own `test-dag`/`test-when`/... corpus)
|
||||||
out of fleet-wide results. Pass `-all` to include everything.
|
out of fleet-wide results. Pass `-all` to include everything.
|
||||||
|
|
||||||
|
`-analyze` also flags two Woodpecker footguns that are statically visible in
|
||||||
|
a step's `commands:` text, both of which have cost a real pipeline run:
|
||||||
|
|
||||||
|
- a stray apostrophe inside a `-c bash -euxc '...'`-style single-quoted
|
||||||
|
shell block — including one inside a `#` comment — closes the quote
|
||||||
|
early and hands the rest of the script to the outer shell, usually
|
||||||
|
surfacing as a baffling "command not found" for a tool that IS
|
||||||
|
installed (oleks/mempalace pipeline 218).
|
||||||
|
- an unescaped `${VAR}` in a step command — Woodpecker substitutes braced
|
||||||
|
`${VAR}` in the raw pipeline text at parse time, before the shell runs,
|
||||||
|
so a shell variable meant for the shell needs to be escaped as `$${VAR}`
|
||||||
|
(Woodpecker's own `${CI_*}` builtins and a pipeline's own `matrix:` axis
|
||||||
|
names are the legitimate unescaped exceptions and aren't flagged).
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -154,6 +154,67 @@ whoever ran this, not part of pipetree's own tracker):
|
|||||||
skipping" instead of an error. Any other status/network failure still
|
skipping" instead of an error. Any other status/network failure still
|
||||||
surfaces as a real error, unchanged.
|
surfaces as a real error, unchanged.
|
||||||
|
|
||||||
|
## Shipped (v0.4, footgun detection)
|
||||||
|
|
||||||
|
`-analyze` now also flags two statically-detectable Woodpecker footguns
|
||||||
|
inside step `commands:` text (`internal/analyze/footguns.go`), on top of
|
||||||
|
the existing shared-image/broken-depends_on checks. Both were motivated by
|
||||||
|
a real pipeline failure (oleks/mempalace, 2026-08-03) and grounded in
|
||||||
|
reading actual incident commits, not guessed:
|
||||||
|
|
||||||
|
- **StrayApostrophe**: the common `nix shell ... -c bash -euxc
|
||||||
|
'...multi-line script...'` idiom wraps the whole script in ONE
|
||||||
|
single-quoted shell argument. Bash gives single quotes no escape
|
||||||
|
mechanism other than the close-escape-reopen trick (`'`, `\`, `''`), so
|
||||||
|
ANY literal `'` inside the block — including inside a `#` comment —
|
||||||
|
closes the quote early and hands everything after it to the outer
|
||||||
|
shell to re-tokenize. Live incident: oleks/mempalace pipeline 218
|
||||||
|
failed `exit 127` ("patch: command not found", though patch WAS
|
||||||
|
installed) because a comment added in `3fb2b02` contained the word
|
||||||
|
"Woodpecker's" — root-caused and fixed in commit `54899ec`
|
||||||
|
(oleks/mempalace#87/#90). The exact same idiom had already been bitten
|
||||||
|
once before (commit `e409e70`, oleks/mempalace#68).
|
||||||
|
|
||||||
|
Detection: find the idiom's opening line (`-c '`/`-euxc '` etc. at the
|
||||||
|
end of a line) and its dedicated closing line (a line that trims to
|
||||||
|
exactly `'`); any additional unescaped `'` strictly between them is
|
||||||
|
flagged. Commands that don't match this precise shape (no dedicated
|
||||||
|
closing line) are left unchecked rather than guessed at, to avoid false
|
||||||
|
positives on differently-structured single-quoted commands.
|
||||||
|
|
||||||
|
- **UnescapedWoodpeckerVar**: Woodpecker substitutes braced `${VAR}` in
|
||||||
|
the raw pipeline YAML text at parse time, before the shell ever runs —
|
||||||
|
including inside comments, since the substitution pass has no concept
|
||||||
|
of what's a comment. A shell variable meant for the shell (e.g.
|
||||||
|
`${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}`) gets mangled (undefined ->
|
||||||
|
empty/malformed text), typically surfacing as an "unbound variable"
|
||||||
|
failure under `set -u`. Fix: escape as `$${VAR}` so Woodpecker's
|
||||||
|
substitution collapses the `$$` to a literal `$`, leaving `${VAR}`
|
||||||
|
intact for the shell (commit `33b880d`, oleks/mempalace#44).
|
||||||
|
|
||||||
|
Detection: any `${NAME...}` not immediately preceded by a second `$`
|
||||||
|
(the `$${...}` escape) and whose name isn't one of Woodpecker's own
|
||||||
|
`CI_*` metadata builtins or the pipeline's own `matrix:` axis names —
|
||||||
|
Woodpecker's matrix feature works BY substituting `${AXIS_NAME}`
|
||||||
|
throughout the YAML with each axis value before parsing, so that's a
|
||||||
|
second class of legitimate unescaped use alongside `CI_*`.
|
||||||
|
|
||||||
|
**False positive found and fixed during fleet validation**: the first
|
||||||
|
fleet-wide run flagged `${TARGET_ARCH}` in `oleks/ii-researcher`,
|
||||||
|
`oleks/csi-s3`, and `oleks/common-chronicle` (18 hits total) — all three
|
||||||
|
declare `matrix: {TARGET_ARCH: [...]}` and reference `${TARGET_ARCH}`
|
||||||
|
unescaped in the step name and commands, which is Woodpecker's matrix
|
||||||
|
feature working exactly as documented, not a mistake. Excluding a
|
||||||
|
pipeline's own matrix axis names (alongside `CI_*`) dropped this to 0
|
||||||
|
false positives; the check still correctly flags real footguns (fleet run
|
||||||
|
after the fix: 0 stray apostrophes, 8 genuine unescaped-var findings in
|
||||||
|
`oleks/deals-site`, `oleks/element-web-patched`, and
|
||||||
|
`oleks/terminal-agent` — shell variables set via step `environment:` or
|
||||||
|
local assignment, not Woodpecker metadata). Verified `oleks/mempalace`
|
||||||
|
(both the exact pipeline-218-broken commit and its fix) and
|
||||||
|
`oleks/oracle-adb-backend` (currently clean) as positive/negative
|
||||||
|
fixtures.
|
||||||
|
|
||||||
## External validation: `woodpecker-cli`
|
## External validation: `woodpecker-cli`
|
||||||
|
|
||||||
The official CLI (`woodpecker-cli`, v3.16.0, matches `ci.oleks.space`) is
|
The official CLI (`woodpecker-cli`, v3.16.0, matches `ci.oleks.space`) is
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
// Package analyze looks across an already-scanned set of projects for
|
// Package analyze looks across an already-scanned set of projects for
|
||||||
// structural smells that don't show up looking at any one pipeline file
|
// structural smells that don't show up looking at any one pipeline file
|
||||||
// in isolation: step images worth consolidating, and depends_on
|
// in isolation: step images worth consolidating, and depends_on
|
||||||
// references that name something that doesn't exist.
|
// references that name something that doesn't exist. It also runs two
|
||||||
|
// single-pipeline-local footgun checks (see footguns.go) alongside the
|
||||||
|
// cross-pipeline ones, since -analyze is already the closest thing this
|
||||||
|
// tool has to a lint mode and both footguns are things a human skims past
|
||||||
|
// without noticing.
|
||||||
//
|
//
|
||||||
// Both depends_on checks below are grounded in reading Woodpecker's own
|
// Both depends_on checks below are grounded in reading Woodpecker's own
|
||||||
// source (git.oleks.space/oleks/woodpecker, pipeline/frontend/yaml/
|
// source (git.oleks.space/oleks/woodpecker, pipeline/frontend/yaml/
|
||||||
@@ -68,6 +72,8 @@ type Report struct {
|
|||||||
SharedImages []SharedImage
|
SharedImages []SharedImage
|
||||||
BrokenStepDependsOn []BrokenStepDependsOn
|
BrokenStepDependsOn []BrokenStepDependsOn
|
||||||
BrokenPipelineDependsOn []BrokenPipelineDependsOn
|
BrokenPipelineDependsOn []BrokenPipelineDependsOn
|
||||||
|
StrayApostrophes []StrayApostrophe
|
||||||
|
UnescapedVars []UnescapedWoodpeckerVar
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run analyzes projects and returns a Report, each section sorted for
|
// Run analyzes projects and returns a Report, each section sorted for
|
||||||
@@ -77,8 +83,12 @@ func Run(projects []*model.Project) Report {
|
|||||||
imageRefs := map[string][]PipelineRef{}
|
imageRefs := map[string][]PipelineRef{}
|
||||||
var brokenStep []BrokenStepDependsOn
|
var brokenStep []BrokenStepDependsOn
|
||||||
var brokenPipeline []BrokenPipelineDependsOn
|
var brokenPipeline []BrokenPipelineDependsOn
|
||||||
|
var strayApostrophes []StrayApostrophe
|
||||||
|
var unescapedVars []UnescapedWoodpeckerVar
|
||||||
|
|
||||||
for _, proj := range projects {
|
for _, proj := range projects {
|
||||||
|
runFootguns(proj, &strayApostrophes, &unescapedVars)
|
||||||
|
|
||||||
pipelineNames := make(map[string]bool, len(proj.Pipelines))
|
pipelineNames := make(map[string]bool, len(proj.Pipelines))
|
||||||
for _, pl := range proj.Pipelines {
|
for _, pl := range proj.Pipelines {
|
||||||
pipelineNames[pl.Name] = true
|
pipelineNames[pl.Name] = true
|
||||||
@@ -147,5 +157,13 @@ func Run(projects []*model.Project) Report {
|
|||||||
return brokenPipeline[i].Pipeline < brokenPipeline[j].Pipeline
|
return brokenPipeline[i].Pipeline < brokenPipeline[j].Pipeline
|
||||||
})
|
})
|
||||||
|
|
||||||
return Report{SharedImages: shared, BrokenStepDependsOn: brokenStep, BrokenPipelineDependsOn: brokenPipeline}
|
sortFootguns(strayApostrophes, unescapedVars)
|
||||||
|
|
||||||
|
return Report{
|
||||||
|
SharedImages: shared,
|
||||||
|
BrokenStepDependsOn: brokenStep,
|
||||||
|
BrokenPipelineDependsOn: brokenPipeline,
|
||||||
|
StrayApostrophes: strayApostrophes,
|
||||||
|
UnescapedVars: unescapedVars,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// Footgun detection for two Woodpecker CI gotchas that are statically
|
||||||
|
// visible in a step's `commands:` text and have both cost a real pipeline
|
||||||
|
// run (oleks/mempalace, 2026-08-03 and originally 2026-07-31):
|
||||||
|
//
|
||||||
|
// 1. StrayApostrophe: the common `nix shell ... -c bash -euxc '...multi-
|
||||||
|
// line script...'` idiom wraps the whole script in ONE single-quoted
|
||||||
|
// shell argument. Bash gives single quotes no escape mechanism at all
|
||||||
|
// (other than the close-escape-reopen trick, written as a single
|
||||||
|
// quote, backslash, then two single quotes) - so ANY literal
|
||||||
|
// `'` inside the block, including inside a `#` comment, closes the
|
||||||
|
// quote early and hands everything after it to the outer shell to
|
||||||
|
// re-tokenize, usually surfacing as a baffling "command not found" for
|
||||||
|
// a tool that IS installed. Live incident: oleks/mempalace pipeline
|
||||||
|
// 218 failed exit 127 ("patch: command not found", patch WAS
|
||||||
|
// installed) because a comment added in 3fb2b02 contained the word
|
||||||
|
// "Woodpecker's" - root-caused and fixed in commit 54899ec
|
||||||
|
// (oleks/mempalace#87/#90). The same class of bug had already bitten
|
||||||
|
// this exact idiom once before (commit e409e70, oleks/mempalace#68:
|
||||||
|
// "test_mcp_http_transport.py's" in a comment) - which is exactly why
|
||||||
|
// it's worth detecting statically rather than trusting people to
|
||||||
|
// remember not to use apostrophes in comments forever.
|
||||||
|
//
|
||||||
|
// 2. UnescapedWoodpeckerVar: Woodpecker substitutes braced `${VAR}`
|
||||||
|
// references in the raw pipeline YAML text itself, at parse time,
|
||||||
|
// before the shell ever runs - including inside comments, since the
|
||||||
|
// substitution pass has no idea what's a comment. A shell variable
|
||||||
|
// meant for the shell (e.g. `${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}`)
|
||||||
|
// gets mangled (undefined -> empty/malformed text), typically
|
||||||
|
// surfacing as an "unbound variable" failure under `set -u`. The fix
|
||||||
|
// is to escape it as `$$` so Woodpecker's substitution collapses that
|
||||||
|
// to a literal `$`, leaving `${VAR}` intact for the shell - see
|
||||||
|
// oleks/mempalace commit 33b880d (oleks/mempalace#44). Woodpecker's
|
||||||
|
// own builtin metadata vars (`${CI_*}`) are the one legitimate
|
||||||
|
// unescaped use and are not flagged.
|
||||||
|
package analyze
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.oleks.space/oleks/pipetree/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StrayApostrophe is one line inside a `-c '...'`-style single-quoted
|
||||||
|
// shell block whose apostrophe isn't the block's own close-escape-reopen
|
||||||
|
// idiom (a single quote, backslash, then two single quotes) - it closes
|
||||||
|
// the quote early, corrupting everything after it in the script.
|
||||||
|
type StrayApostrophe struct {
|
||||||
|
Project string
|
||||||
|
Pipeline string
|
||||||
|
Step string
|
||||||
|
Line int // 1-based line number within the command's text (not the file)
|
||||||
|
Context string // the offending line, trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnescapedWoodpeckerVar is a `${VAR}` in a step command that Woodpecker
|
||||||
|
// will substitute at parse time (before the shell runs) because it isn't
|
||||||
|
// escaped as `$${VAR}` and isn't one of Woodpecker's own `CI_*` builtins.
|
||||||
|
type UnescapedWoodpeckerVar struct {
|
||||||
|
Project string
|
||||||
|
Pipeline string
|
||||||
|
Step string
|
||||||
|
Line int
|
||||||
|
Var string
|
||||||
|
Context string
|
||||||
|
}
|
||||||
|
|
||||||
|
// openQuoteRe matches a line (right-trimmed) ending in a `-c '`/`-euxc '`
|
||||||
|
// style flag that opens a single-quoted shell argument - the idiom is
|
||||||
|
// `... -c bash -euxc '` as the last thing on the line, with the script
|
||||||
|
// body starting on the next line.
|
||||||
|
var openQuoteRe = regexp.MustCompile(`-\S*c\s+'$`)
|
||||||
|
|
||||||
|
// dollarBraceRe matches `${NAME...}` - NAME must start with a letter or
|
||||||
|
// underscore (excludes positional/special params like ${1} or ${@}, which
|
||||||
|
// Woodpecker's metadata substitution doesn't touch anyway) and may be
|
||||||
|
// followed by any parameter-expansion suffix (`:-default`, `:+alt`, ...).
|
||||||
|
var dollarBraceRe = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)[^}]*\}`)
|
||||||
|
|
||||||
|
// woodpeckerBuiltinVar reports whether name is one of Woodpecker's own
|
||||||
|
// substituted variables, for which an unescaped `${...}` is the correct,
|
||||||
|
// intended usage rather than a footgun:
|
||||||
|
// - namespaced CI_* metadata vars (pipeline/frontend/metadata package);
|
||||||
|
// - this pipeline's own `matrix:` axis names - Woodpecker's matrix
|
||||||
|
// feature works BY replacing `${AXIS_NAME}` throughout the YAML text
|
||||||
|
// with each axis value before parsing, so e.g. `matrix: {TARGET_ARCH:
|
||||||
|
// [...]}` + `${TARGET_ARCH}` in a step name/command is the documented,
|
||||||
|
// correct way to use it, not a mistake (confirmed against real fleet
|
||||||
|
// pipelines: oleks/ii-researcher, oleks/csi-s3, oleks/common-chronicle
|
||||||
|
// all use this pattern deliberately).
|
||||||
|
func woodpeckerBuiltinVar(name string, matrixVars map[string]bool) bool {
|
||||||
|
return strings.HasPrefix(name, "CI_") || matrixVars[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
// findStrayApostrophes scans one command string for the stray-apostrophe
|
||||||
|
// footgun. It looks for the `-c '` idiom's opening line, then the nearest
|
||||||
|
// following line that is - once trimmed - exactly `'` (the idiom's own
|
||||||
|
// dedicated closing line). Any additional, non-escaped `'` strictly
|
||||||
|
// between those two lines closes the shell's quote early. Commands with no
|
||||||
|
// such dedicated closing line are left alone rather than guessed at, to
|
||||||
|
// avoid false positives on differently-shaped single-quoted commands.
|
||||||
|
func findStrayApostrophes(cmd string) []struct {
|
||||||
|
Line int
|
||||||
|
Context string
|
||||||
|
} {
|
||||||
|
var hits []struct {
|
||||||
|
Line int
|
||||||
|
Context string
|
||||||
|
}
|
||||||
|
lines := strings.Split(cmd, "\n")
|
||||||
|
for i, raw := range lines {
|
||||||
|
if !openQuoteRe.MatchString(strings.TrimRight(raw, " \t\r")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
closeIdx := -1
|
||||||
|
for j := i + 1; j < len(lines); j++ {
|
||||||
|
if strings.TrimSpace(lines[j]) == "'" {
|
||||||
|
closeIdx = j
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if closeIdx == -1 {
|
||||||
|
// No dedicated closing line found for this opening - the
|
||||||
|
// script doesn't match the idiom precisely enough to check
|
||||||
|
// reliably, so skip rather than risk a false positive.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for k := i + 1; k < closeIdx; k++ {
|
||||||
|
// Strip the legitimate `'\''` close-escape-reopen idiom before
|
||||||
|
// looking for a stray quote.
|
||||||
|
stripped := strings.ReplaceAll(lines[k], `'\''`, "")
|
||||||
|
if strings.Contains(stripped, "'") {
|
||||||
|
hits = append(hits, struct {
|
||||||
|
Line int
|
||||||
|
Context string
|
||||||
|
}{Line: k + 1, Context: strings.TrimSpace(lines[k])})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits
|
||||||
|
}
|
||||||
|
|
||||||
|
// findUnescapedVars scans one command string for `${VAR}` references that
|
||||||
|
// Woodpecker will substitute at parse time: any `${...}` not immediately
|
||||||
|
// preceded by a second `$` (the `$${...}` escape that hands the expansion
|
||||||
|
// to the shell) and whose var name isn't one of Woodpecker's own `CI_*`
|
||||||
|
// builtins or this pipeline's own matrix axis names (see
|
||||||
|
// woodpeckerBuiltinVar). matrixVars may be nil.
|
||||||
|
func findUnescapedVars(cmd string, matrixVars map[string]bool) []struct {
|
||||||
|
Line int
|
||||||
|
Var string
|
||||||
|
Context string
|
||||||
|
} {
|
||||||
|
var hits []struct {
|
||||||
|
Line int
|
||||||
|
Var string
|
||||||
|
Context string
|
||||||
|
}
|
||||||
|
lines := strings.Split(cmd, "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
for _, loc := range dollarBraceRe.FindAllStringSubmatchIndex(line, -1) {
|
||||||
|
start := loc[0]
|
||||||
|
name := line[loc[2]:loc[3]]
|
||||||
|
if start > 0 && line[start-1] == '$' {
|
||||||
|
continue // $${...} - correctly escaped for the shell
|
||||||
|
}
|
||||||
|
if woodpeckerBuiltinVar(name, matrixVars) {
|
||||||
|
continue // Woodpecker's own builtin metadata or matrix var
|
||||||
|
}
|
||||||
|
hits = append(hits, struct {
|
||||||
|
Line int
|
||||||
|
Var string
|
||||||
|
Context string
|
||||||
|
}{Line: i + 1, Var: name, Context: trimmed})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits
|
||||||
|
}
|
||||||
|
|
||||||
|
// runFootguns appends the two footgun checks' findings (for one project's
|
||||||
|
// pipelines) onto the accumulators passed in.
|
||||||
|
func runFootguns(proj *model.Project, apos *[]StrayApostrophe, vars *[]UnescapedWoodpeckerVar) {
|
||||||
|
for _, pl := range proj.Pipelines {
|
||||||
|
var matrixVars map[string]bool
|
||||||
|
if len(pl.Matrix) > 0 {
|
||||||
|
matrixVars = make(map[string]bool, len(pl.Matrix))
|
||||||
|
for axis := range pl.Matrix {
|
||||||
|
matrixVars[axis] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range pl.Steps {
|
||||||
|
for _, cmd := range s.Commands {
|
||||||
|
for _, h := range findStrayApostrophes(cmd) {
|
||||||
|
*apos = append(*apos, StrayApostrophe{
|
||||||
|
Project: proj.Name, Pipeline: pl.Name, Step: s.Name,
|
||||||
|
Line: h.Line, Context: h.Context,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, h := range findUnescapedVars(cmd, matrixVars) {
|
||||||
|
*vars = append(*vars, UnescapedWoodpeckerVar{
|
||||||
|
Project: proj.Name, Pipeline: pl.Name, Step: s.Name,
|
||||||
|
Line: h.Line, Var: h.Var, Context: h.Context,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortFootguns(apos []StrayApostrophe, vars []UnescapedWoodpeckerVar) {
|
||||||
|
sort.Slice(apos, func(i, j int) bool {
|
||||||
|
if apos[i].Project != apos[j].Project {
|
||||||
|
return apos[i].Project < apos[j].Project
|
||||||
|
}
|
||||||
|
if apos[i].Pipeline != apos[j].Pipeline {
|
||||||
|
return apos[i].Pipeline < apos[j].Pipeline
|
||||||
|
}
|
||||||
|
if apos[i].Step != apos[j].Step {
|
||||||
|
return apos[i].Step < apos[j].Step
|
||||||
|
}
|
||||||
|
return apos[i].Line < apos[j].Line
|
||||||
|
})
|
||||||
|
sort.Slice(vars, func(i, j int) bool {
|
||||||
|
if vars[i].Project != vars[j].Project {
|
||||||
|
return vars[i].Project < vars[j].Project
|
||||||
|
}
|
||||||
|
if vars[i].Pipeline != vars[j].Pipeline {
|
||||||
|
return vars[i].Pipeline < vars[j].Pipeline
|
||||||
|
}
|
||||||
|
if vars[i].Step != vars[j].Step {
|
||||||
|
return vars[i].Step < vars[j].Step
|
||||||
|
}
|
||||||
|
return vars[i].Line < vars[j].Line
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package analyze
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.oleks.space/oleks/pipetree/internal/model"
|
||||||
|
"git.oleks.space/oleks/pipetree/internal/parse"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loadProject parses one pipeline file (a testdata fixture) into a
|
||||||
|
// single-pipeline model.Project, the same shape buildProjects (main.go)
|
||||||
|
// produces for a real scan.
|
||||||
|
func loadProject(t *testing.T, name, file string) *model.Project {
|
||||||
|
t.Helper()
|
||||||
|
pl, err := parse.File(file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse.File(%s): %v", file, err)
|
||||||
|
}
|
||||||
|
pl.Name = "default"
|
||||||
|
return &model.Project{Name: name, Path: file, Pipelines: []*model.Pipeline{pl}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStrayApostrophe_PositiveCase reproduces the exact shape of the bug
|
||||||
|
// that broke oleks/mempalace pipeline 218 on 2026-08-03 (commit 54899ec^,
|
||||||
|
// fixed by 54899ec, oleks/mempalace#87/#90): a comment inside the
|
||||||
|
// `-c bash -euxc '...'` block containing the word "Woodpecker's" closes
|
||||||
|
// the quote early, dropping the rest of the script to the outer shell -
|
||||||
|
// the pipeline failed with a baffling "patch: command not found" (exit
|
||||||
|
// 127) even though patch was installed, just not on the fallback shell's
|
||||||
|
// PATH. Verifies pipetree catches it.
|
||||||
|
func TestStrayApostrophe_PositiveCase(t *testing.T) {
|
||||||
|
proj := loadProject(t, "mempalace", "testdata/mempalace-stray-apostrophe.yaml")
|
||||||
|
report := Run([]*model.Project{proj})
|
||||||
|
|
||||||
|
if len(report.StrayApostrophes) == 0 {
|
||||||
|
t.Fatal("expected at least one stray apostrophe finding, got none")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, a := range report.StrayApostrophes {
|
||||||
|
if a.Step != "pytest-ruff" {
|
||||||
|
t.Errorf("finding on unexpected step %q", a.Step)
|
||||||
|
}
|
||||||
|
found = found || strings.Contains(a.Context, "Woodpecker's")
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected a finding pointing at the offending line (\"...Woodpecker's...\"), got: %+v", report.StrayApostrophes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnescapedVar_PositiveCase reproduces the shape of the bug fixed in
|
||||||
|
// oleks/mempalace commit 33b880d (oleks/mempalace#44): an unescaped
|
||||||
|
// `${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}` that Woodpecker substitutes at
|
||||||
|
// parse time, before the shell runs. The same file also legitimately uses
|
||||||
|
// `${CI_COMMIT_TAG:-v3.5.0}` (a real Woodpecker builtin), which must NOT
|
||||||
|
// be flagged.
|
||||||
|
func TestUnescapedVar_PositiveCase(t *testing.T) {
|
||||||
|
proj := loadProject(t, "mempalace", "testdata/mempalace-unescaped-var.yaml")
|
||||||
|
report := Run([]*model.Project{proj})
|
||||||
|
|
||||||
|
var gotLDLibraryPath, gotCIBuiltin bool
|
||||||
|
for _, u := range report.UnescapedVars {
|
||||||
|
if u.Var == "LD_LIBRARY_PATH" {
|
||||||
|
gotLDLibraryPath = true
|
||||||
|
}
|
||||||
|
if u.Var == "CI_COMMIT_TAG" {
|
||||||
|
gotCIBuiltin = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !gotLDLibraryPath {
|
||||||
|
t.Errorf("expected LD_LIBRARY_PATH to be flagged as an unescaped shell var, got: %+v", report.UnescapedVars)
|
||||||
|
}
|
||||||
|
if gotCIBuiltin {
|
||||||
|
t.Errorf("CI_COMMIT_TAG is a legitimate Woodpecker builtin and must not be flagged, got: %+v", report.UnescapedVars)
|
||||||
|
}
|
||||||
|
// This fixture's single-quoted shell block has no apostrophe footgun.
|
||||||
|
if len(report.StrayApostrophes) != 0 {
|
||||||
|
t.Errorf("expected no stray-apostrophe findings in this fixture, got: %+v", report.StrayApostrophes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFootguns_NegativeCase_KnownGoodFixture is the current (post-fix),
|
||||||
|
// live oleks/mempalace .woodpecker/test.yaml: correctly-escaped $${...}
|
||||||
|
// var references, a legitimate ${CI_COMMIT_TAG} builtin use outside any
|
||||||
|
// single-quoted block, and an apostrophe-free -c bash -euxc '...' block.
|
||||||
|
// Regression guard for false positives - this exact file was flagged as
|
||||||
|
// "known-good post-fix" and a hit here would need fixing before shipping.
|
||||||
|
func TestFootguns_NegativeCase_KnownGoodFixture(t *testing.T) {
|
||||||
|
proj := loadProject(t, "mempalace", "testdata/mempalace-fixed.yaml")
|
||||||
|
report := Run([]*model.Project{proj})
|
||||||
|
|
||||||
|
if len(report.StrayApostrophes) != 0 {
|
||||||
|
t.Errorf("false positive: expected no stray-apostrophe findings on the known-good fixture, got: %+v", report.StrayApostrophes)
|
||||||
|
}
|
||||||
|
if len(report.UnescapedVars) != 0 {
|
||||||
|
t.Errorf("false positive: expected no unescaped-var findings on the known-good fixture, got: %+v", report.UnescapedVars)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFootguns_NegativeCase_MatrixVar guards against the false positive
|
||||||
|
// found while validating against the real fleet: oleks/ii-researcher (and
|
||||||
|
// equivalently oleks/csi-s3, oleks/common-chronicle) declares `matrix:
|
||||||
|
// {TARGET_ARCH: [...]}` and legitimately references `${TARGET_ARCH}`
|
||||||
|
// unescaped in the step name and commands - that's Woodpecker's own
|
||||||
|
// matrix-substitution feature working as intended, not a footgun, and
|
||||||
|
// must not be flagged.
|
||||||
|
func TestFootguns_NegativeCase_MatrixVar(t *testing.T) {
|
||||||
|
proj := loadProject(t, "ii-researcher", "testdata/matrix-fixture.yaml")
|
||||||
|
report := Run([]*model.Project{proj})
|
||||||
|
|
||||||
|
for _, u := range report.UnescapedVars {
|
||||||
|
if u.Var == "TARGET_ARCH" {
|
||||||
|
t.Errorf("false positive: TARGET_ARCH is this pipeline's own matrix axis, must not be flagged: %+v", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindStrayApostrophes_Unit exercises the line-level detector directly
|
||||||
|
// against small synthetic commands, independent of YAML parsing.
|
||||||
|
func TestFindStrayApostrophes_Unit(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cmd string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "clean block, no apostrophe",
|
||||||
|
cmd: "nix shell nixpkgs#bash -c bash -euxc '\n" +
|
||||||
|
" echo hello\n" +
|
||||||
|
" echo world\n" +
|
||||||
|
"'",
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "apostrophe in a comment ends the quote early",
|
||||||
|
cmd: "nix shell nixpkgs#bash -c bash -euxc '\n" +
|
||||||
|
" # this is Woodpecker's escape hatch\n" +
|
||||||
|
" echo hello\n" +
|
||||||
|
"'",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "legitimate close-escape-reopen idiom is not flagged",
|
||||||
|
cmd: "nix shell nixpkgs#bash -c bash -euxc '\n" +
|
||||||
|
" echo '\\''it'\\''s fine'\\''\n" +
|
||||||
|
"'",
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no dedicated closing line - not checked (avoid false positive)",
|
||||||
|
cmd: "nix shell nixpkgs#bash -c bash -euxc 'echo hi'",
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no single-quoted -c block at all",
|
||||||
|
cmd: "echo \"it's a plain command, no -c '...' idiom here\"",
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := findStrayApostrophes(tc.cmd)
|
||||||
|
if len(got) != tc.want {
|
||||||
|
t.Errorf("findStrayApostrophes(%q) = %d hits %+v, want %d", tc.cmd, len(got), got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindUnescapedVars_Unit exercises the ${VAR} detector directly.
|
||||||
|
func TestFindUnescapedVars_Unit(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cmd string
|
||||||
|
matrixVars map[string]bool
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "correctly escaped shell var",
|
||||||
|
cmd: `export LD_LIBRARY_PATH="$CCLIB/lib$${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"`,
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unescaped shell var",
|
||||||
|
cmd: `export LD_LIBRARY_PATH="$CCLIB/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"`,
|
||||||
|
want: []string{"LD_LIBRARY_PATH"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "legitimate unescaped Woodpecker builtin",
|
||||||
|
cmd: `VERSION=$(printf "%s" "${CI_COMMIT_TAG:-v3.5.0}")`,
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unescaped var mentioned inside a comment still bites",
|
||||||
|
cmd: "# don't forget ${MY_SECRET} needs escaping\necho hi",
|
||||||
|
want: []string{"MY_SECRET"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare $VAR and $(...) are unaffected",
|
||||||
|
cmd: `echo $HOME && VER=$(date +%s)`,
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Woodpecker's matrix feature works BY substituting ${AXIS}
|
||||||
|
// throughout the YAML with each axis value before parsing -
|
||||||
|
// confirmed live in oleks/ii-researcher, oleks/csi-s3,
|
||||||
|
// oleks/common-chronicle. This is the correct, intended use,
|
||||||
|
// not a footgun, so a pipeline's own matrix axis names must
|
||||||
|
// not be flagged.
|
||||||
|
name: "matrix axis var is not a footgun",
|
||||||
|
cmd: `ci/local.sh --arch "${TARGET_ARCH}"`,
|
||||||
|
matrixVars: map[string]bool{"TARGET_ARCH": true},
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-matrix var still flagged even with a matrix in scope",
|
||||||
|
cmd: `echo "${OTHER_VAR}"`,
|
||||||
|
matrixVars: map[string]bool{"TARGET_ARCH": true},
|
||||||
|
want: []string{"OTHER_VAR"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := findUnescapedVars(tc.cmd, tc.matrixVars)
|
||||||
|
if len(got) != len(tc.want) {
|
||||||
|
t.Fatalf("findUnescapedVars(%q) = %+v, want vars %v", tc.cmd, got, tc.want)
|
||||||
|
}
|
||||||
|
for i, w := range tc.want {
|
||||||
|
if got[i].Var != w {
|
||||||
|
t.Errorf("hit %d: var = %q, want %q", i, got[i].Var, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
labels:
|
||||||
|
arch: amd64
|
||||||
|
|
||||||
|
clone:
|
||||||
|
- name: clone
|
||||||
|
image: woodpeckerci/plugin-git
|
||||||
|
environment:
|
||||||
|
CI_NETRC_MACHINE: git.oleks.space
|
||||||
|
CI_NETRC_USERNAME: oleks
|
||||||
|
CI_NETRC_PASSWORD:
|
||||||
|
from_secret: gitea_clone_token
|
||||||
|
PLUGIN_TAGS: "false"
|
||||||
|
PLUGIN_DEPTH: "1"
|
||||||
|
|
||||||
|
# DISABLED: replaced by .woodpecker/arm64.yaml and .woodpecker/amd64.yaml
|
||||||
|
# which run on native arch nodes.
|
||||||
|
when:
|
||||||
|
- event: manual
|
||||||
|
evaluate: "false"
|
||||||
|
|
||||||
|
# Matrix: uncomment additional arches to enable multi-arch builds
|
||||||
|
# Remote buildkit workers do the native-arch build per target.
|
||||||
|
matrix:
|
||||||
|
TARGET_ARCH:
|
||||||
|
# - s390x
|
||||||
|
- arm64
|
||||||
|
# - amd64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: build-api-${TARGET_ARCH}
|
||||||
|
image: git.oleks.space/oleks/nix-ci:latest
|
||||||
|
environment:
|
||||||
|
REGISTRY_TOKEN:
|
||||||
|
from_secret: registry_token
|
||||||
|
commands:
|
||||||
|
- echo "▸ arch=$(uname -m)"
|
||||||
|
- |
|
||||||
|
TAG=$(echo "$CI_COMMIT_TAG" | sed 's/^v//')
|
||||||
|
CHECK=$(curl -sf -H "Authorization: token $REGISTRY_TOKEN" \
|
||||||
|
"https://git.oleks.space/api/v1/packages/oleks?type=container&q=ii-researcher/api&limit=50" || echo "")
|
||||||
|
if echo "$CHECK" | grep -q "\"version\":\"$TAG-${TARGET_ARCH}\""; then
|
||||||
|
echo "Image ii-researcher/api:$TAG-${TARGET_ARCH} already exists, skipping build"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
- echo "$REGISTRY_TOKEN" | docker login git.oleks.space -u oleks --password-stdin
|
||||||
|
- |
|
||||||
|
BUILDER_HOST="buildkit-rootless-${TARGET_ARCH}.infra.svc.cluster.local"
|
||||||
|
BUILDER_PORT="1234"
|
||||||
|
echo "Waiting for builder..."
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if echo >/dev/tcp/$BUILDER_HOST/$BUILDER_PORT 2>/dev/null; then
|
||||||
|
echo "Builder ready"; break
|
||||||
|
fi
|
||||||
|
[ "$i" -eq 30 ] && echo "Builder not available" && exit 1
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
docker buildx create --name ${TARGET_ARCH}-api --driver remote "tcp://$BUILDER_HOST:$BUILDER_PORT"
|
||||||
|
TAG=$(echo "$CI_COMMIT_TAG" | sed 's/^v//')
|
||||||
|
docker buildx build \
|
||||||
|
--builder ${TARGET_ARCH}-api \
|
||||||
|
--platform linux/${TARGET_ARCH} \
|
||||||
|
--build-arg REGISTRY_TOKEN="$REGISTRY_TOKEN" \
|
||||||
|
-f Dockerfile.api \
|
||||||
|
-t "git.oleks.space/oleks/ii-researcher/api:$TAG-${TARGET_ARCH}" \
|
||||||
|
--push .
|
||||||
|
backend_options:
|
||||||
|
kubernetes:
|
||||||
|
nodeSelector:
|
||||||
|
provider: digitalocean
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 6Gi
|
||||||
|
limits:
|
||||||
|
memory: 6Gi
|
||||||
|
labels:
|
||||||
|
commit-tag: "${CI_COMMIT_TAG}"
|
||||||
|
commit-branch: "${CI_COMMIT_BRANCH}"
|
||||||
|
pipeline-number: "${CI_PIPELINE_NUMBER}"
|
||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
# CI gate for the patched mempalace tree (oleks/mempalace#44).
|
||||||
|
#
|
||||||
|
# The image build applies an ordered stack of downstream patches to the upstream
|
||||||
|
# v3.5.0 tarball (see flake.nix `patches = [ ... ]`), but the build itself does
|
||||||
|
# NOT run the test suite — so a behavior patch that breaks an upstream test, or
|
||||||
|
# pushes a function over ruff's C901 budget, would ship silently. This job
|
||||||
|
# reconstructs the exact patched tree and runs pytest + ruff so patch drift
|
||||||
|
# fails CI on push/PR, BEFORE a release tag builds an image.
|
||||||
|
#
|
||||||
|
# DRY: the patch list + order are read straight out of flake.nix (the single
|
||||||
|
# source of truth), so this can never fall out of sync with what the image builds.
|
||||||
|
# arch: arm64 — mempalace is arm64-only; an amd64 label mis-scheduled this onto
|
||||||
|
# agents that report labels:null and the long clone got context-canceled
|
||||||
|
# (oleks/mempalace#41 CI clone-cancel root cause).
|
||||||
|
labels:
|
||||||
|
arch: arm64
|
||||||
|
|
||||||
|
# push+pull_request together double-run every commit on a branch with an open
|
||||||
|
# PR (oleks/cluster#364's trigger case) -- push is scoped to main (post-merge
|
||||||
|
# validation) so pull_request alone covers feature branches.
|
||||||
|
when:
|
||||||
|
- event: pull_request
|
||||||
|
- event: push
|
||||||
|
branch: main
|
||||||
|
- event: manual
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: pytest-ruff
|
||||||
|
image: git.oleks.space/oleks/nix-ci:latest
|
||||||
|
environment:
|
||||||
|
# ci/setup.sh writes a git.oleks.space netrc from this — without it,
|
||||||
|
# evaluating this flake's private git+https inputs (fleet-pins,
|
||||||
|
# heatwave-backend, parity-lib) to resolve `.#devShells.<system>.test`
|
||||||
|
# fails the same way flake-check.yaml's GITEA_CLONE_TOKEN comment
|
||||||
|
# describes (oleks/mempalace#57).
|
||||||
|
GITEA_CLONE_TOKEN:
|
||||||
|
from_secret: gitea_clone_token
|
||||||
|
commands:
|
||||||
|
- echo "▸ arch=$(uname -m)"
|
||||||
|
- sh ci/setup.sh
|
||||||
|
# `nix develop .#test` now evaluates this repo's own flake.nix (unlike the
|
||||||
|
# old bare `nix shell nixpkgs#...`, which never touched it), and flake.nix
|
||||||
|
# unconditionally `import`s the gitignored version.nix — so it has to exist
|
||||||
|
# (and be `git add -f`'d, matching flake-check.yaml/arm64.yaml: a local git
|
||||||
|
# flake's pure eval only sees git-tracked/staged files) before any output
|
||||||
|
# can be evaluated, even one that doesn't otherwise care about VERSION.
|
||||||
|
- VERSION=$(echo "${CI_COMMIT_TAG:-v3.5.0}" | sed 's/^v//; s/-[0-9]*$//')
|
||||||
|
- printf '"%s"\n' "$VERSION" > version.nix && git add -f version.nix
|
||||||
|
# oleks/mempalace#68/#73/#325 fix: narrow the test shell to lightweight direct
|
||||||
|
# deps only (uv, ruff, python3, curl, tar, gzip, sed, grep, patch, coreutils),
|
||||||
|
# pinned to THIS repo's nixpkgs (fleet/nixpkgs-ci) via flake.nix's input pins.
|
||||||
|
# This avoids evaluating the full flake.nix (which pulls in image-build
|
||||||
|
# infrastructure + deep transitive closures), and restores the fast,
|
||||||
|
# reproducible cache locality from pre-#73. The #68 PATH fix (pinned uv/ruff)
|
||||||
|
# is preserved: nix shell resolves them against the fleet pin, not live
|
||||||
|
# nixpkgs-unstable. oleks/cluster#325 (slow attic cache) no longer dominates
|
||||||
|
# the setup phase because we're not pulling full image-build closures anymore.
|
||||||
|
# Resolved out here, where `nix` is still on PATH (inside `nix shell` it is
|
||||||
|
# not). Exported so the apostrophe-free block below can consume it.
|
||||||
|
- export CXX_LIB_DIR="$(nix eval --raw nixpkgs#stdenv.cc.cc.lib.outPath)/lib"
|
||||||
|
- |
|
||||||
|
# Read nixpkgs from flake.lock to ensure we use the fleet-pinned version,
|
||||||
|
# matching what the full flake would use. Avoids nixpkgs-unstable drift.
|
||||||
|
nix shell \
|
||||||
|
"nixpkgs#uv" \
|
||||||
|
"nixpkgs#ruff" \
|
||||||
|
"nixpkgs#python312" \
|
||||||
|
"nixpkgs#stdenv.cc.cc.lib" \
|
||||||
|
"nixpkgs#curl" \
|
||||||
|
"nixpkgs#gnutar" \
|
||||||
|
"nixpkgs#gzip" \
|
||||||
|
"nixpkgs#gnused" \
|
||||||
|
"nixpkgs#gnugrep" \
|
||||||
|
"nixpkgs#patch" \
|
||||||
|
"nixpkgs#coreutils" \
|
||||||
|
-c bash -euxc '
|
||||||
|
# Explicit bash, not the base images bare /bin/sh (dash/busybox,
|
||||||
|
# unknown which) — see oleks/mempalace#68 for the debugging this
|
||||||
|
# replaced. All tools above come from the pinned nixpkgs, so
|
||||||
|
# they are guaranteed present on PATH without a retry/poll loop.
|
||||||
|
UV_BIN=$(command -v uv)
|
||||||
|
RUFF_BIN=$(command -v ruff)
|
||||||
|
echo "resolved: uv=$UV_BIN ruff=$RUFF_BIN"
|
||||||
|
# Use the nix-provided CPython, NOT a uv-downloaded standalone one: the
|
||||||
|
# nix-ci image has no nix-ld, so uv-fetched interpreters cannot find their
|
||||||
|
# dynamic loader ("Python interpreter not found"). only-system + never
|
||||||
|
# makes uv build the venv from python3.12 on PATH.
|
||||||
|
export UV_PYTHON_DOWNLOADS=never UV_PYTHON_PREFERENCE=only-system
|
||||||
|
|
||||||
|
# numpy/chromadb arrive as manylinux wheels that dlopen libstdc++.so.6,
|
||||||
|
# which the nix-ci image does not provide (no nix-ld either). devShells.test
|
||||||
|
# sets this, but a6f9b5c stopped CI from using that shell when it narrowed
|
||||||
|
# the step to a bare `nix shell` for cache locality (#73/#325) -- and dropped
|
||||||
|
# stdenv.cc.cc.lib with it, regressing the #190/PR#77 fix (88cfa1b) and
|
||||||
|
# leaving main red (oleks/mempalace#87/#90). Re-added here rather than
|
||||||
|
# reverting to `nix develop .#test`, which would pull back the image-build
|
||||||
|
# closures a6f9b5c deliberately removed.
|
||||||
|
#
|
||||||
|
# NOTE: this whole block is a single-quoted `bash -euxc ...` argument, so
|
||||||
|
# an apostrophe anywhere in it -- including in a comment -- terminates the
|
||||||
|
# quote early and silently drops the rest of the script back to /bin/sh
|
||||||
|
# (seen live: pipeline 218 failed with "patch: command not found").
|
||||||
|
# Keep this block apostrophe-free.
|
||||||
|
#
|
||||||
|
# LIB_DIR is resolved OUTSIDE this shell and passed in via the environment:
|
||||||
|
# `nix shell` puts only the listed packages on PATH, and nix itself is not
|
||||||
|
# one of them, so `nix eval` in here would be command-not-found.
|
||||||
|
export LD_LIBRARY_PATH="$${CXX_LIB_DIR}$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH}"
|
||||||
|
|
||||||
|
# version.nix is gitignored; take it from the release tag when present
|
||||||
|
# (v3.5.0-7 -> 3.5.0), else the current default (bump with version.nix).
|
||||||
|
VERSION=$(printf "%s" "$${CI_COMMIT_TAG:-v3.5.0}" | sed "s/^v//; s/-[0-9]*$//")
|
||||||
|
echo "mempalace version=$VERSION"
|
||||||
|
|
||||||
|
curl -fsSL "https://github.com/milla-jovovich/mempalace/archive/refs/tags/v$VERSION.tar.gz" | tar xz
|
||||||
|
cd "mempalace-$VERSION"
|
||||||
|
|
||||||
|
# Apply the patch stack in the EXACT order flake.nix declares it,
|
||||||
|
# parsed from the `patches = [ ... ];` block (no hand-maintained copy).
|
||||||
|
PATCHES=$(sed -n "/patches = \[/,/\];/p" ../flake.nix | grep -oE "mempalace-[a-z0-9-]+\.patch")
|
||||||
|
echo "applying:"; echo "$PATCHES"
|
||||||
|
for p in $PATCHES; do echo "+ $p"; patch -p1 < "../$p"; done
|
||||||
|
|
||||||
|
# uv pulls the test deps (real chromadb, prometheus_client, pyyaml,
|
||||||
|
# ruff) into an ephemeral env; the package imports from the patched
|
||||||
|
# tree via PYTHONPATH — same invocation a developer runs locally.
|
||||||
|
# mcp/starlette/uvicorn/httpx (oleks/mempalace#27): the streamable-HTTP
|
||||||
|
# transport test file exercises the real SDK client/server over a real
|
||||||
|
# loopback socket, matching the real-socket approach the legacy HTTP
|
||||||
|
# transport test file (test_mcp_http_transport.py) already uses.
|
||||||
|
PYTHONPATH=. "$UV_BIN" run --python python3.12 --with pytest --with chromadb --with pyyaml --with prometheus_client \
|
||||||
|
--with "mcp>=1.29,<2" --with starlette --with uvicorn --with httpx \
|
||||||
|
pytest tests/test_mcp_server.py tests/test_mcp_streamable_http.py -q -p no:cacheprovider
|
||||||
|
"$RUFF_BIN" check mempalace/mcp_server.py mempalace/mcp_streamable_http.py
|
||||||
|
'
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# CI gate for the patched mempalace tree (oleks/mempalace#44).
|
||||||
|
#
|
||||||
|
# The image build applies an ordered stack of downstream patches to the upstream
|
||||||
|
# v3.5.0 tarball (see flake.nix `patches = [ ... ]`), but the build itself does
|
||||||
|
# NOT run the test suite — so a behavior patch that breaks an upstream test, or
|
||||||
|
# pushes a function over ruff's C901 budget, would ship silently. This job
|
||||||
|
# reconstructs the exact patched tree and runs pytest + ruff so patch drift
|
||||||
|
# fails CI on push/PR, BEFORE a release tag builds an image.
|
||||||
|
#
|
||||||
|
# DRY: the patch list + order are read straight out of flake.nix (the single
|
||||||
|
# source of truth), so this can never fall out of sync with what the image builds.
|
||||||
|
# arch: arm64 — mempalace is arm64-only; an amd64 label mis-scheduled this onto
|
||||||
|
# agents that report labels:null and the long clone got context-canceled
|
||||||
|
# (oleks/mempalace#41 CI clone-cancel root cause).
|
||||||
|
labels:
|
||||||
|
arch: arm64
|
||||||
|
|
||||||
|
# push+pull_request together double-run every commit on a branch with an open
|
||||||
|
# PR (oleks/cluster#364's trigger case) -- push is scoped to main (post-merge
|
||||||
|
# validation) so pull_request alone covers feature branches.
|
||||||
|
when:
|
||||||
|
- event: pull_request
|
||||||
|
- event: push
|
||||||
|
branch: main
|
||||||
|
- event: manual
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: pytest-ruff
|
||||||
|
image: git.oleks.space/oleks/nix-ci:latest
|
||||||
|
environment:
|
||||||
|
# ci/setup.sh writes a git.oleks.space netrc from this — without it,
|
||||||
|
# evaluating this flake's private git+https inputs (fleet-pins,
|
||||||
|
# heatwave-backend, parity-lib) to resolve `.#devShells.<system>.test`
|
||||||
|
# fails the same way flake-check.yaml's GITEA_CLONE_TOKEN comment
|
||||||
|
# describes (oleks/mempalace#57).
|
||||||
|
GITEA_CLONE_TOKEN:
|
||||||
|
from_secret: gitea_clone_token
|
||||||
|
commands:
|
||||||
|
- echo "▸ arch=$(uname -m)"
|
||||||
|
- sh ci/setup.sh
|
||||||
|
# `nix develop .#test` now evaluates this repo's own flake.nix (unlike the
|
||||||
|
# old bare `nix shell nixpkgs#...`, which never touched it), and flake.nix
|
||||||
|
# unconditionally `import`s the gitignored version.nix — so it has to exist
|
||||||
|
# (and be `git add -f`'d, matching flake-check.yaml/arm64.yaml: a local git
|
||||||
|
# flake's pure eval only sees git-tracked/staged files) before any output
|
||||||
|
# can be evaluated, even one that doesn't otherwise care about VERSION.
|
||||||
|
- VERSION=$(echo "${CI_COMMIT_TAG:-v3.5.0}" | sed 's/^v//; s/-[0-9]*$//')
|
||||||
|
- printf '"%s"\n' "$VERSION" > version.nix && git add -f version.nix
|
||||||
|
# oleks/mempalace#68/#73/#325 fix: narrow the test shell to lightweight direct
|
||||||
|
# deps only (uv, ruff, python3, curl, tar, gzip, sed, grep, patch, coreutils),
|
||||||
|
# pinned to THIS repo's nixpkgs (fleet/nixpkgs-ci) via flake.nix's input pins.
|
||||||
|
# This avoids evaluating the full flake.nix (which pulls in image-build
|
||||||
|
# infrastructure + deep transitive closures), and restores the fast,
|
||||||
|
# reproducible cache locality from pre-#73. The #68 PATH fix (pinned uv/ruff)
|
||||||
|
# is preserved: nix shell resolves them against the fleet pin, not live
|
||||||
|
# nixpkgs-unstable. oleks/cluster#325 (slow attic cache) no longer dominates
|
||||||
|
# the setup phase because we're not pulling full image-build closures anymore.
|
||||||
|
- |
|
||||||
|
# Read nixpkgs from flake.lock to ensure we use the fleet-pinned version,
|
||||||
|
# matching what the full flake would use. Avoids nixpkgs-unstable drift.
|
||||||
|
nix shell \
|
||||||
|
"nixpkgs#uv" \
|
||||||
|
"nixpkgs#ruff" \
|
||||||
|
"nixpkgs#python312" \
|
||||||
|
"nixpkgs#stdenv.cc.cc.lib" \
|
||||||
|
"nixpkgs#curl" \
|
||||||
|
"nixpkgs#gnutar" \
|
||||||
|
"nixpkgs#gzip" \
|
||||||
|
"nixpkgs#gnused" \
|
||||||
|
"nixpkgs#gnugrep" \
|
||||||
|
"nixpkgs#patch" \
|
||||||
|
"nixpkgs#coreutils" \
|
||||||
|
-c bash -euxc '
|
||||||
|
# Explicit bash, not the base images bare /bin/sh (dash/busybox,
|
||||||
|
# unknown which) — see oleks/mempalace#68 for the debugging this
|
||||||
|
# replaced. All tools above come from the pinned nixpkgs, so
|
||||||
|
# they are guaranteed present on PATH without a retry/poll loop.
|
||||||
|
UV_BIN=$(command -v uv)
|
||||||
|
RUFF_BIN=$(command -v ruff)
|
||||||
|
echo "resolved: uv=$UV_BIN ruff=$RUFF_BIN"
|
||||||
|
# Use the nix-provided CPython, NOT a uv-downloaded standalone one: the
|
||||||
|
# nix-ci image has no nix-ld, so uv-fetched interpreters cannot find their
|
||||||
|
# dynamic loader ("Python interpreter not found"). only-system + never
|
||||||
|
# makes uv build the venv from python3.12 on PATH.
|
||||||
|
export UV_PYTHON_DOWNLOADS=never UV_PYTHON_PREFERENCE=only-system
|
||||||
|
|
||||||
|
# numpy/chromadb arrive as manylinux wheels that dlopen libstdc++.so.6,
|
||||||
|
# which the nix-ci image does not provide (no nix-ld either). devShells.test
|
||||||
|
# sets this, but a6f9b5c stopped CI from using that shell when it narrowed
|
||||||
|
# the step to a bare `nix shell` for cache locality (#73/#325) -- and dropped
|
||||||
|
# stdenv.cc.cc.lib with it, regressing the #190/PR#77 fix (88cfa1b) and
|
||||||
|
# leaving main red (oleks/mempalace#87/#90). Re-add it here rather than
|
||||||
|
# reverting to `nix develop .#test`, which would pull the image-build
|
||||||
|
# closures a6f9b5c deliberately removed. $$ escapes Woodpecker's own
|
||||||
|
# substitution so the shell owns the expansion (same as 33b880d).
|
||||||
|
export LD_LIBRARY_PATH="$$(nix eval --raw nixpkgs#stdenv.cc.cc.lib.outPath)/lib$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH}"
|
||||||
|
|
||||||
|
# version.nix is gitignored; take it from the release tag when present
|
||||||
|
# (v3.5.0-7 -> 3.5.0), else the current default (bump with version.nix).
|
||||||
|
VERSION=$(printf "%s" "$${CI_COMMIT_TAG:-v3.5.0}" | sed "s/^v//; s/-[0-9]*$//")
|
||||||
|
echo "mempalace version=$VERSION"
|
||||||
|
|
||||||
|
curl -fsSL "https://github.com/milla-jovovich/mempalace/archive/refs/tags/v$VERSION.tar.gz" | tar xz
|
||||||
|
cd "mempalace-$VERSION"
|
||||||
|
|
||||||
|
# Apply the patch stack in the EXACT order flake.nix declares it,
|
||||||
|
# parsed from the `patches = [ ... ];` block (no hand-maintained copy).
|
||||||
|
PATCHES=$(sed -n "/patches = \[/,/\];/p" ../flake.nix | grep -oE "mempalace-[a-z0-9-]+\.patch")
|
||||||
|
echo "applying:"; echo "$PATCHES"
|
||||||
|
for p in $PATCHES; do echo "+ $p"; patch -p1 < "../$p"; done
|
||||||
|
|
||||||
|
# uv pulls the test deps (real chromadb, prometheus_client, pyyaml,
|
||||||
|
# ruff) into an ephemeral env; the package imports from the patched
|
||||||
|
# tree via PYTHONPATH — same invocation a developer runs locally.
|
||||||
|
# mcp/starlette/uvicorn/httpx (oleks/mempalace#27): the streamable-HTTP
|
||||||
|
# transport test file exercises the real SDK client/server over a real
|
||||||
|
# loopback socket, matching the real-socket approach the legacy HTTP
|
||||||
|
# transport test file (test_mcp_http_transport.py) already uses.
|
||||||
|
PYTHONPATH=. "$UV_BIN" run --python python3.12 --with pytest --with chromadb --with pyyaml --with prometheus_client \
|
||||||
|
--with "mcp>=1.29,<2" --with starlette --with uvicorn --with httpx \
|
||||||
|
pytest tests/test_mcp_server.py tests/test_mcp_streamable_http.py -q -p no:cacheprovider
|
||||||
|
"$RUFF_BIN" check mempalace/mcp_server.py mempalace/mcp_streamable_http.py
|
||||||
|
'
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# CI gate for the patched mempalace tree (oleks/mempalace#44).
|
||||||
|
#
|
||||||
|
# The image build applies an ordered stack of downstream patches to the upstream
|
||||||
|
# v3.5.0 tarball (see flake.nix `patches = [ ... ]`), but the build itself does
|
||||||
|
# NOT run the test suite — so a behavior patch that breaks an upstream test, or
|
||||||
|
# pushes a function over ruff's C901 budget, would ship silently. This job
|
||||||
|
# reconstructs the exact patched tree and runs pytest + ruff so patch drift
|
||||||
|
# fails CI on push/PR, BEFORE a release tag builds an image.
|
||||||
|
#
|
||||||
|
# DRY: the patch list + order are read straight out of flake.nix (the single
|
||||||
|
# source of truth), so this can never fall out of sync with what the image builds.
|
||||||
|
# arch: arm64 — mempalace is arm64-only; an amd64 label mis-scheduled this onto
|
||||||
|
# agents that report labels:null and the long clone got context-canceled
|
||||||
|
# (oleks/mempalace#41 CI clone-cancel root cause).
|
||||||
|
labels:
|
||||||
|
arch: arm64
|
||||||
|
|
||||||
|
when:
|
||||||
|
- event: [push, pull_request, manual]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: pytest-ruff
|
||||||
|
image: git.oleks.space/oleks/nix-ci:latest
|
||||||
|
commands:
|
||||||
|
- echo "▸ arch=$(uname -m)"
|
||||||
|
# Everything runs inside one nix shell so the exact tool set is guaranteed
|
||||||
|
# regardless of the base image contents.
|
||||||
|
- |
|
||||||
|
nix shell nixpkgs#uv nixpkgs#python312 nixpkgs#curl nixpkgs#gnutar nixpkgs#gzip \
|
||||||
|
nixpkgs#gnused nixpkgs#gnugrep nixpkgs#patch nixpkgs#coreutils \
|
||||||
|
-c sh -euxc '
|
||||||
|
# Use the nix-provided CPython, NOT a uv-downloaded standalone one: the
|
||||||
|
# nix-ci image has no nix-ld, so uv-fetched interpreters cannot find their
|
||||||
|
# dynamic loader ("Python interpreter not found"). only-system + never
|
||||||
|
# makes uv build the venv from python3.12 on PATH.
|
||||||
|
export UV_PYTHON_DOWNLOADS=never UV_PYTHON_PREFERENCE=only-system
|
||||||
|
# manylinux wheel .so files (pydantic-core, chromadb deps) are dlopened by
|
||||||
|
# the interpreter and need libstdc++/libgcc on the loader path (no /usr/lib
|
||||||
|
# on NixOS).
|
||||||
|
CCLIB=$(nix build --no-link --print-out-paths nixpkgs#stdenv.cc.cc.lib)
|
||||||
|
export LD_LIBRARY_PATH="$CCLIB/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||||
|
|
||||||
|
# version.nix is gitignored; take it from the release tag when present
|
||||||
|
# (v3.5.0-7 -> 3.5.0), else the current default (bump with version.nix).
|
||||||
|
VERSION=$(printf "%s" "${CI_COMMIT_TAG:-v3.5.0}" | sed "s/^v//; s/-[0-9]*$//")
|
||||||
|
echo "mempalace version=$VERSION"
|
||||||
|
|
||||||
|
curl -fsSL "https://github.com/milla-jovovich/mempalace/archive/refs/tags/v$VERSION.tar.gz" | tar xz
|
||||||
|
cd "mempalace-$VERSION"
|
||||||
|
|
||||||
|
# Apply the patch stack in the EXACT order flake.nix declares it,
|
||||||
|
# parsed from the `patches = [ ... ];` block (no hand-maintained copy).
|
||||||
|
PATCHES=$(sed -n "/patches = \[/,/\];/p" ../flake.nix | grep -oE "mempalace-[a-z0-9-]+\.patch")
|
||||||
|
echo "applying:"; echo "$PATCHES"
|
||||||
|
for p in $PATCHES; do echo "+ $p"; patch -p1 < "../$p"; done
|
||||||
|
|
||||||
|
# uv pulls the test deps (real chromadb, prometheus_client, pyyaml,
|
||||||
|
# ruff) into an ephemeral env; the package imports from the patched
|
||||||
|
# tree via PYTHONPATH — same invocation a developer runs locally.
|
||||||
|
PYTHONPATH=. uv run --python python3.12 --with pytest --with chromadb --with pyyaml --with prometheus_client \
|
||||||
|
pytest tests/test_mcp_server.py -q -p no:cacheprovider
|
||||||
|
uv run --python python3.12 --with ruff ruff check mempalace/mcp_server.py
|
||||||
|
'
|
||||||
@@ -26,4 +26,14 @@ func Analyze(w io.Writer, report analyze.Report) {
|
|||||||
for _, b := range report.BrokenPipelineDependsOn {
|
for _, b := range report.BrokenPipelineDependsOn {
|
||||||
_, _ = fmt.Fprintf(w, " %s / %s depends_on %q (not found)\n", b.Project, b.Pipeline, b.MissingName)
|
_, _ = fmt.Fprintf(w, " %s / %s depends_on %q (not found)\n", b.Project, b.Pipeline, b.MissingName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, _ = fmt.Fprintf(w, "\n%d stray apostrophe(s) inside a single-quoted shell block (closes the quote early, drops the rest of the script to the outer shell):\n", len(report.StrayApostrophes))
|
||||||
|
for _, a := range report.StrayApostrophes {
|
||||||
|
_, _ = fmt.Fprintf(w, " %s / %s / step %s, line %d: %s\n", a.Project, a.Pipeline, a.Step, a.Line, a.Context)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = fmt.Fprintf(w, "\n%d unescaped ${VAR} in step commands (Woodpecker substitutes these at parse time, before the shell runs - escape as $${VAR} to hand it to the shell):\n", len(report.UnescapedVars))
|
||||||
|
for _, u := range report.UnescapedVars {
|
||||||
|
_, _ = fmt.Fprintf(w, " %s / %s / step %s, line %d: ${%s} in %s\n", u.Project, u.Pipeline, u.Step, u.Line, u.Var, u.Context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user