forgedock 1.18.0 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +13 -0
- package/bin/engine/phases.mjs +54 -4
- package/bin/engine/runlog.mjs +10 -2
- package/bin/engine/telemetry-seam.mjs +3 -0
- package/bin/engine/telemetry.test.mjs +3 -2
- package/bin/engine-cli.mjs +32 -2
- package/bin/engine.mjs +139 -7
- package/bin/runner.mjs +69 -9
- package/bin/stats/aggregate.mjs +7 -3
- package/bin/stats/cache.mjs +7 -3
- package/bin/stats/github-source.mjs +136 -28
- package/bin/stats/index.mjs +46 -9
- package/bin/stats/render.mjs +1 -1
- package/bin/stats/stats.test.mjs +201 -40
- package/commands/review-pr-staging.md +4 -4
- package/commands/review-pr.md +25 -15
- package/commands/work-on/build/architect.md +53 -0
- package/commands/work-on/build/implement.md +19 -0
- package/commands/work-on/build/validate.md +320 -28
- package/commands/work-on/build.md +66 -9
- package/commands/work-on/investigate.md +7 -0
- package/commands/work-on/remediate.md +32 -13
- package/commands/work-on/review.md +32 -19
- package/docs/CONFIG.md +89 -2
- package/docs/OUTCOME-CODES.md +5 -1
- package/docs/STATS.md +11 -4
- package/docs/TELEMETRY.md +5 -2
- package/docs/WORK-ON-RUNTIME.md +18 -0
- package/package.json +2 -2
- package/packages/protocol/src/types.js +1 -1
- package/scripts/acceptance-red-green.sh +90 -0
- package/scripts/acceptance-red-green.test.sh +74 -0
- package/scripts/cap-findings.test.sh +2 -0
- package/scripts/check-contract-scope.test.sh +2 -0
- package/scripts/check-edge-cases.sh +143 -0
- package/scripts/check-edge-cases.test.sh +175 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/check-test-tmp-cleanup.sh +56 -0
- package/scripts/check-test-tmp-cleanup.test.sh +69 -0
- package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
- package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
- package/scripts/ci-local/adapters/buildkite.mjs +58 -0
- package/scripts/ci-local/adapters/circleci.mjs +101 -0
- package/scripts/ci-local/adapters/common.mjs +115 -0
- package/scripts/ci-local/adapters/explicit.mjs +21 -0
- package/scripts/ci-local/adapters/github-actions.mjs +349 -0
- package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
- package/scripts/ci-local/adapters/portability.mjs +104 -0
- package/scripts/ci-local/adapters/task-runners.mjs +67 -0
- package/scripts/ci-local/cli.mjs +50 -0
- package/scripts/ci-local/lib.mjs +371 -0
- package/scripts/ci-local/plan.mjs +186 -0
- package/scripts/ci-local/run.mjs +345 -0
- package/scripts/ci-local/scoping.mjs +508 -0
- package/scripts/ci-local.sh +97 -0
- package/scripts/ci-local.test.sh +919 -0
- package/scripts/classify-finding.sh +32 -6
- package/scripts/classify-finding.test.sh +23 -0
- package/scripts/classify-test-run.sh +69 -0
- package/scripts/classify-test-run.test.sh +90 -0
- package/scripts/close-parent-c3.test.sh +2 -0
- package/scripts/consumer-sweep.sh +569 -0
- package/scripts/consumer-sweep.test.sh +357 -0
- package/scripts/detect-append-registries.sh +106 -0
- package/scripts/detect-append-registries.test.sh +64 -0
- package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
- package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
- package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
- package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
- package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
- package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
- package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
- package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
- package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
- package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
- package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
- package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
- package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
- package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
- package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
- package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
- package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
- package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
- package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
- package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
- package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
- package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
- package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
- package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
- package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
- package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
- package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
- package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/lib/telemetry.sh +1 -0
- package/scripts/remediate-approved-head.test.sh +2 -0
- package/scripts/remediate-basesync.test.sh +1 -1
- package/scripts/review-acceptance-gate.sh +2 -2
- package/scripts/review-acceptance-gate.test.sh +20 -0
- package/scripts/review-delta.sh +8 -1
- package/scripts/review-delta.test.sh +19 -0
- package/scripts/review-finalize.sh +193 -41
- package/scripts/review-finalize.test.sh +211 -4
- package/scripts/review-pr-delta-wiring.test.sh +13 -0
- package/scripts/review-prepare.sh +5 -3
- package/scripts/review-prepare.test.sh +18 -2
- package/scripts/run-shell-tests.sh +5 -0
- package/scripts/telemetry.test.sh +2 -0
- package/scripts/trusted-comments.test.sh +2 -1
- package/scripts/verify-phase-trail.test.sh +8 -0
- package/scripts/wait-ci-green.sh +61 -9
- package/scripts/wait-ci-green.test.sh +41 -3
- package/scripts/with-run-tmpdir.sh +20 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
# Fixtures for scripts/check-edge-cases.sh. Run: bash scripts/check-edge-cases.test.sh (bash 3.2 compatible)
|
|
6
|
+
|
|
7
|
+
set -u
|
|
8
|
+
DIR=$(cd "$(dirname "$0")" && pwd)
|
|
9
|
+
S="$DIR/check-edge-cases.sh"
|
|
10
|
+
T=$(mktemp -d "${TMPDIR:-/tmp}/check-edge-cases-test.XXXXXX")
|
|
11
|
+
trap 'rm -rf "$T"' EXIT
|
|
12
|
+
pass=0; fail=0
|
|
13
|
+
OUT=""; RC=0
|
|
14
|
+
|
|
15
|
+
g() { git -C "$T/r" -c user.name=t -c user.email=t@example.com -c commit.gpgsign=false "$@" >/dev/null 2>&1; }
|
|
16
|
+
run() { OUT=$(bash "$S" --architect "$T/a" --builder "$T/b" --base staging --repo "$T/r" 2>&1); RC=$?; }
|
|
17
|
+
arch() { { echo '## Plan'; echo '<!-- FORGE:EDGE_CASES:BEGIN -->'; cat; echo '<!-- FORGE:EDGE_CASES:END -->'; echo '<!-- FORGE:ARCHITECT:COMPLETE -->'; } > "$T/a"; }
|
|
18
|
+
builder() { { echo '<!-- FORGE:BUILDER -->'; echo '### Edge Case Coverage'; cat; } > "$T/b"; }
|
|
19
|
+
expect() { # expect <want_rc> <desc> [want_substring]
|
|
20
|
+
if [ "$RC" = "$1" ] && { [ -z "${3:-}" ] || case "$OUT" in *"$3"*) true ;; *) false ;; esac; }; then
|
|
21
|
+
pass=$((pass+1))
|
|
22
|
+
else
|
|
23
|
+
fail=$((fail+1)); echo "FAIL: $2 (rc=$RC, want $1)"; printf '%s\n' "$OUT" | sed 's/^/ /'
|
|
24
|
+
fi
|
|
25
|
+
}
|
|
26
|
+
expect_absent() { case "$OUT" in *"$2"*) fail=$((fail+1)); echo "FAIL: $1 (unexpected '$2')"; printf '%s\n' "$OUT" | sed 's/^/ /' ;; *) pass=$((pass+1)) ;; esac; }
|
|
27
|
+
reset_branch() { g checkout -q -f staging; g branch -q -D feat; g checkout -q -b feat; }
|
|
28
|
+
|
|
29
|
+
mkdir "$T/r"; git -C "$T/r" init -q -b staging >/dev/null 2>&1 || { git -C "$T/r" init -q; g checkout -b staging; }
|
|
30
|
+
mkdir -p "$T/r/tests"
|
|
31
|
+
printf 'def test_existing_case():\n assert True\n' > "$T/r/tests/test_a.py"
|
|
32
|
+
g add -A; g commit -m base
|
|
33
|
+
g checkout -q -b feat
|
|
34
|
+
|
|
35
|
+
E1='EDGE: id=e1 kind=edge description=empty input returns nothing'
|
|
36
|
+
E2='EDGE: id=e2 kind=error description=upstream times out with several classes of error'
|
|
37
|
+
add_test() { printf 'def %s():\n assert True\n' "$1" >> "$T/r/tests/test_a.py"; g add -A; g commit -m "add $1"; }
|
|
38
|
+
|
|
39
|
+
# (a) empty block / absent block pass
|
|
40
|
+
arch </dev/null; builder </dev/null; run; expect 0 "empty block passes" "SKIP"
|
|
41
|
+
printf '## Plan\nno block\n' > "$T/a"; : > "$T/b"; run; expect 0 "absent block passes" "SKIP"
|
|
42
|
+
|
|
43
|
+
# (b) unmapped id fails
|
|
44
|
+
arch <<M
|
|
45
|
+
$E1
|
|
46
|
+
M
|
|
47
|
+
builder </dev/null; run; expect 1 "unmapped id fails" "UNMAPPED: e1"
|
|
48
|
+
|
|
49
|
+
# (c) untestable with a reason passes; (d) empty reason fails
|
|
50
|
+
builder <<M
|
|
51
|
+
EDGE_TEST: id=e1 untestable=pure spec change, no runtime to exercise
|
|
52
|
+
M
|
|
53
|
+
run; expect 0 "untestable with reason passes" "OK:"
|
|
54
|
+
builder <<M
|
|
55
|
+
EDGE_TEST: id=e1 untestable=
|
|
56
|
+
M
|
|
57
|
+
run; expect 1 "empty untestable reason fails" "EMPTY_REASON: e1"
|
|
58
|
+
builder <<M
|
|
59
|
+
EDGE_TEST: id=e1 untestable=
|
|
60
|
+
M
|
|
61
|
+
run; expect 1 "whitespace-only untestable reason fails" "EMPTY_REASON: e1"
|
|
62
|
+
|
|
63
|
+
# (e) mapped test absent from the diff fails; present in added lines passes
|
|
64
|
+
builder <<M
|
|
65
|
+
EDGE_TEST: id=e1 test=tests/test_a.py::test_empty_input
|
|
66
|
+
M
|
|
67
|
+
run; expect 1 "mapped test not in diff fails" "TEST_NOT_IN_DIFF: e1"
|
|
68
|
+
add_test test_empty_input
|
|
69
|
+
run; expect 0 "mapped test in committed added lines passes" "MAPPED_TESTS: 1"
|
|
70
|
+
|
|
71
|
+
# (h) a pre-existing test name never satisfies a new edge case
|
|
72
|
+
builder <<M
|
|
73
|
+
EDGE_TEST: id=e1 test=tests/test_a.py::test_existing_case
|
|
74
|
+
M
|
|
75
|
+
run; expect 1 "pre-existing test name fails" "TEST_NOT_IN_DIFF: e1"
|
|
76
|
+
|
|
77
|
+
# the name must be in the mapped file, not just anywhere in the diff
|
|
78
|
+
mkdir -p "$T/r/other"; printf 'def test_elsewhere():\n pass\n' > "$T/r/other/test_b.py"; g add -A; g commit -m other
|
|
79
|
+
builder <<M
|
|
80
|
+
EDGE_TEST: id=e1 test=tests/test_a.py::test_elsewhere
|
|
81
|
+
M
|
|
82
|
+
run; expect 1 "name added in a different file fails" "TEST_NOT_IN_DIFF: e1"
|
|
83
|
+
|
|
84
|
+
# staged-but-uncommitted and untracked tests count (validate runs before the commit)
|
|
85
|
+
printf 'def test_staged_case():\n pass\n' >> "$T/r/tests/test_a.py"; g add -A
|
|
86
|
+
printf 'def test_untracked_case():\n pass\n' > "$T/r/tests/test_new.py"
|
|
87
|
+
arch <<M
|
|
88
|
+
$E1
|
|
89
|
+
$E2
|
|
90
|
+
M
|
|
91
|
+
builder <<M
|
|
92
|
+
EDGE_TEST: id=e1 test=tests/test_a.py::test_staged_case
|
|
93
|
+
EDGE_TEST: id=e2 test=tests/test_new.py::test_untracked_case
|
|
94
|
+
M
|
|
95
|
+
run; expect 0 "staged and untracked tests count" "MAPPED_TESTS: 2"
|
|
96
|
+
reset_branch
|
|
97
|
+
|
|
98
|
+
# (g) duplicate ids fail (architect side and builder side)
|
|
99
|
+
arch <<M
|
|
100
|
+
$E1
|
|
101
|
+
EDGE: id=e1 kind=error description=same id twice
|
|
102
|
+
M
|
|
103
|
+
builder <<M
|
|
104
|
+
EDGE_TEST: id=e1 untestable=reason one
|
|
105
|
+
M
|
|
106
|
+
run; expect 1 "duplicate architect id fails" "DUPLICATE_ID: e1"
|
|
107
|
+
arch <<M
|
|
108
|
+
$E1
|
|
109
|
+
M
|
|
110
|
+
builder <<M
|
|
111
|
+
EDGE_TEST: id=e1 untestable=reason one
|
|
112
|
+
EDGE_TEST: id=e1 untestable=reason two
|
|
113
|
+
M
|
|
114
|
+
run; expect 1 "duplicate EDGE_TEST id fails" "DUPLICATE_ID: e1"
|
|
115
|
+
|
|
116
|
+
# mixed: one covered, one unmapped -> only the unmapped id is named
|
|
117
|
+
arch <<M
|
|
118
|
+
$E1
|
|
119
|
+
$E2
|
|
120
|
+
M
|
|
121
|
+
builder <<M
|
|
122
|
+
- EDGE_TEST: id=e1 untestable=reason given
|
|
123
|
+
M
|
|
124
|
+
run; expect 1 "bulleted EDGE_TEST line parses; other id unmapped" "UNMAPPED: e2"
|
|
125
|
+
expect_absent "covered id is not reported" "UNMAPPED: e1"
|
|
126
|
+
|
|
127
|
+
# prose mentioning EDGE_TEST mid-line is not a record
|
|
128
|
+
builder <<M
|
|
129
|
+
Note: each id needs an EDGE_TEST: line later.
|
|
130
|
+
EDGE_TEST: id=e1 untestable=reason given
|
|
131
|
+
EDGE_TEST: id=e2 untestable=another reason
|
|
132
|
+
M
|
|
133
|
+
run; expect 0 "mid-line EDGE_TEST mention ignored" "OK:"
|
|
134
|
+
|
|
135
|
+
# (f) bad input is rc 2, never a pass
|
|
136
|
+
builder <<M
|
|
137
|
+
EDGE_TEST: id=e1 untestable=r
|
|
138
|
+
EDGE_TEST: id=e2 test=no-separator
|
|
139
|
+
M
|
|
140
|
+
run; expect 2 "test= without ::name is rc 2" "must be <path>::<name>"
|
|
141
|
+
builder <<M
|
|
142
|
+
EDGE_TEST: id=e1 bogus=1
|
|
143
|
+
M
|
|
144
|
+
run; expect 2 "malformed EDGE_TEST is rc 2" "malformed EDGE_TEST"
|
|
145
|
+
builder <<M
|
|
146
|
+
EDGE_TEST: id=e1 test=--evil::x
|
|
147
|
+
M
|
|
148
|
+
run; expect 2 "test path starting with - is rc 2"
|
|
149
|
+
printf '<!-- FORGE:EDGE_CASES:BEGIN -->\nEDGE: id=bad kind=edge description=x\n<!-- FORGE:EDGE_CASES:END -->\n' > "$T/a"; : > "$T/b"
|
|
150
|
+
run; expect 2 "malformed EDGE id is rc 2" "malformed EDGE record"
|
|
151
|
+
printf '<!-- FORGE:EDGE_CASES:BEGIN -->\nEDGE: id=e1 kind=edge description=x\n' > "$T/a"
|
|
152
|
+
run; expect 2 "unbalanced markers are rc 2" "unbalanced"
|
|
153
|
+
arch <<M
|
|
154
|
+
$E1
|
|
155
|
+
M
|
|
156
|
+
builder <<M
|
|
157
|
+
EDGE_TEST: id=e1 untestable=r
|
|
158
|
+
M
|
|
159
|
+
OUT=$(bash "$S" --architect "$T/a" --builder "$T/b" --base nonexistent --repo "$T/r" 2>&1); RC=$?; expect 2 "unresolvable base is rc 2" "cannot resolve base"
|
|
160
|
+
mkdir -p "$T/notgit"
|
|
161
|
+
OUT=$(bash "$S" --architect "$T/a" --builder "$T/b" --base staging --repo "$T/notgit" 2>&1); RC=$?; expect 2 "non-git repo is rc 2"
|
|
162
|
+
OUT=$(bash "$S" --architect "$T/missing" --builder "$T/b" --base staging --repo "$T/r" 2>&1); RC=$?; expect 2 "missing architect file is rc 2"
|
|
163
|
+
OUT=$(bash "$S" --base staging 2>&1); RC=$?; expect 2 "missing args is rc 2"
|
|
164
|
+
|
|
165
|
+
# description with spaces and punctuation is data, never parsed or executed
|
|
166
|
+
arch <<'M'
|
|
167
|
+
EDGE: id=e1 kind=concurrency description=two writers $(touch /tmp/pwned) `x` "quoted" a=b
|
|
168
|
+
M
|
|
169
|
+
builder <<M
|
|
170
|
+
EDGE_TEST: id=e1 untestable=reason given
|
|
171
|
+
M
|
|
172
|
+
run; expect 0 "free-text description is inert" "OK:"
|
|
173
|
+
|
|
174
|
+
echo "check-edge-cases.test.sh: $pass passed, $fail failed"
|
|
175
|
+
[ "$fail" -eq 0 ]
|
|
@@ -108,12 +108,14 @@ MARKER_REGISTRY="
|
|
|
108
108
|
DECISION_RECORD
|
|
109
109
|
DECOMPOSED
|
|
110
110
|
DECOMPOSE_BLOCKED
|
|
111
|
+
DEGRADED_REREVIEW
|
|
111
112
|
DEFERRED_FROM
|
|
112
113
|
DESIGN
|
|
113
114
|
DIFF_SIZE
|
|
114
115
|
DISPATCH
|
|
115
116
|
DISPATCHER
|
|
116
117
|
DOSSIER_UPDATED
|
|
118
|
+
EDGE_CASES
|
|
117
119
|
ENGINE_FALLBACK
|
|
118
120
|
FAST_PATH
|
|
119
121
|
FINDING_SOURCE
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
# check-test-tmp-cleanup.sh — fail when a test suite creates a temp dir with no cleanup.
|
|
6
|
+
#
|
|
7
|
+
# Conservative text check (no parsing):
|
|
8
|
+
# - bin/**/*.test.mjs that mentions mkdtemp/mkdtempSync must import the shared
|
|
9
|
+
# helper bin/tests/_tmp.mjs (tmpDir registers removal at process exit).
|
|
10
|
+
# - scripts/*.test.sh that calls mktemp must register an EXIT trap.
|
|
11
|
+
# The helper itself (bin/tests/_tmp.mjs) is not a *.test.mjs file and is not scanned.
|
|
12
|
+
#
|
|
13
|
+
# Usage: bash scripts/check-test-tmp-cleanup.sh [ROOT]
|
|
14
|
+
# ROOT defaults to the repo root; the test points it at fixture trees.
|
|
15
|
+
# Exit: 0 clean, 1 offenders found (printed one per line), 2 bad ROOT.
|
|
16
|
+
# Bash 3.2 portable: no mapfile, no associative arrays.
|
|
17
|
+
|
|
18
|
+
set -u
|
|
19
|
+
|
|
20
|
+
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
21
|
+
ROOT="${1:-$(cd "$SCRIPT_DIR/.." && pwd)}"
|
|
22
|
+
[ -d "$ROOT" ] || { echo "check-test-tmp-cleanup: ROOT is not a directory: $ROOT" >&2; exit 2; }
|
|
23
|
+
|
|
24
|
+
BAD=0
|
|
25
|
+
|
|
26
|
+
if [ -d "$ROOT/bin" ]; then
|
|
27
|
+
NODE_FILES=$(find "$ROOT/bin" -name '*.test.mjs' -not -path '*/node_modules/*' | sort)
|
|
28
|
+
while IFS= read -r f; do
|
|
29
|
+
[ -n "$f" ] || continue
|
|
30
|
+
grep -qE 'mkdtemp(Sync)?[[:space:]]*\(|^[[:space:]]*mkdtemp(Sync)?,?[[:space:]]*$|import[^;]*mkdtemp' "$f" || continue
|
|
31
|
+
if ! grep -qE "^[[:space:]]*import[^;]*from[[:space:]]+['\"][./]+(tests/)?_tmp\.mjs['\"]" "$f"; then
|
|
32
|
+
echo "VIOLATION: ${f#"$ROOT"/}: uses mkdtemp without importing bin/tests/_tmp.mjs (use tmpDir)"
|
|
33
|
+
BAD=1
|
|
34
|
+
fi
|
|
35
|
+
done <<EOF_NODE
|
|
36
|
+
${NODE_FILES}
|
|
37
|
+
EOF_NODE
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
if [ -d "$ROOT/scripts" ]; then
|
|
41
|
+
SH_FILES=$(find "$ROOT/scripts" -maxdepth 1 -name '*.test.sh' | sort)
|
|
42
|
+
while IFS= read -r f; do
|
|
43
|
+
[ -n "$f" ] || continue
|
|
44
|
+
# a mktemp call: skip comment lines and quoted mentions inside grep -F patterns
|
|
45
|
+
grep -E '(^|[^[:alnum:]_.-])mktemp([[:space:]]|\)|$)' "$f" | grep -vE '^[[:space:]]*#' | grep -vE "grep[^|]*mktemp" | grep -q . || continue
|
|
46
|
+
if ! grep -qE '^[^#]*\btrap\b[^#]*\bEXIT\b' "$f"; then
|
|
47
|
+
echo "VIOLATION: ${f#"$ROOT"/}: uses mktemp without an EXIT trap"
|
|
48
|
+
BAD=1
|
|
49
|
+
fi
|
|
50
|
+
done <<EOF_SH
|
|
51
|
+
${SH_FILES}
|
|
52
|
+
EOF_SH
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
[ "$BAD" -eq 0 ] && echo "check-test-tmp-cleanup: OK"
|
|
56
|
+
exit "$BAD"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
# Tests for scripts/check-test-tmp-cleanup.sh (passing and failing fixtures, plus the real tree).
|
|
6
|
+
# Run: bash scripts/check-test-tmp-cleanup.test.sh (bash 3.2 compatible)
|
|
7
|
+
|
|
8
|
+
set -u
|
|
9
|
+
DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
10
|
+
LINT="$DIR/check-test-tmp-cleanup.sh"
|
|
11
|
+
T=$(mktemp -d "${TMPDIR:-/tmp}/check-test-tmp-cleanup-test.XXXXXX")
|
|
12
|
+
trap 'rm -rf "$T"' EXIT
|
|
13
|
+
|
|
14
|
+
PASS=0; FAIL=0
|
|
15
|
+
ok() { PASS=$((PASS + 1)); echo " ok - $1"; }
|
|
16
|
+
bad() { FAIL=$((FAIL + 1)); echo " FAIL - $1"; }
|
|
17
|
+
|
|
18
|
+
# run_case NAME EXPECT_RC EXPECT_OUT_SUBSTR (fixture tree = $T/$NAME)
|
|
19
|
+
run_case() {
|
|
20
|
+
local name="$1" want_rc="$2" want_out="$3" out rc
|
|
21
|
+
out=$(bash "$LINT" "$T/$name" 2>&1); rc=$?
|
|
22
|
+
[ "$rc" -eq "$want_rc" ] && ok "$name: exit $want_rc" || bad "$name: exit $rc, wanted $want_rc ($out)"
|
|
23
|
+
case "$out" in *"$want_out"*) ok "$name: output mentions '$want_out'" ;; *) bad "$name: output lacks '$want_out' ($out)" ;; esac
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
mk() { mkdir -p "$T/$1/bin/tests" "$T/$1/scripts"; }
|
|
27
|
+
|
|
28
|
+
mk pass_node
|
|
29
|
+
printf 'import { tmpDir } from "./_tmp.mjs";\nconst d = tmpDir("fd-x-");\n' > "$T/pass_node/bin/tests/a.test.mjs"
|
|
30
|
+
run_case pass_node 0 "OK"
|
|
31
|
+
|
|
32
|
+
mk pass_node_nested
|
|
33
|
+
mkdir -p "$T/pass_node_nested/bin/stats"
|
|
34
|
+
printf 'import { tmpDir } from "../tests/_tmp.mjs";\nconst d = tmpDir("fd-x-");\n' > "$T/pass_node_nested/bin/stats/a.test.mjs"
|
|
35
|
+
run_case pass_node_nested 0 "OK"
|
|
36
|
+
|
|
37
|
+
mk fail_node
|
|
38
|
+
printf 'import { mkdtempSync } from "node:fs";\nconst d = mkdtempSync("/tmp/fd-x-");\n' > "$T/fail_node/bin/tests/b.test.mjs"
|
|
39
|
+
run_case fail_node 1 "b.test.mjs"
|
|
40
|
+
|
|
41
|
+
mk fail_node_multiline_import
|
|
42
|
+
printf 'import {\n mkdtempSync,\n rmSync,\n} from "node:fs";\n' > "$T/fail_node_multiline_import/bin/tests/c.test.mjs"
|
|
43
|
+
run_case fail_node_multiline_import 1 "c.test.mjs"
|
|
44
|
+
|
|
45
|
+
mk pass_sh
|
|
46
|
+
printf '#!/usr/bin/env bash\nT=$(mktemp -d)\ntrap '"'"'rm -rf "$T"'"'"' EXIT\n' > "$T/pass_sh/scripts/a.test.sh"
|
|
47
|
+
run_case pass_sh 0 "OK"
|
|
48
|
+
|
|
49
|
+
mk fail_sh
|
|
50
|
+
printf '#!/usr/bin/env bash\nT=$(mktemp -d)\necho hi\n' > "$T/fail_sh/scripts/b.test.sh"
|
|
51
|
+
run_case fail_sh 1 "b.test.sh"
|
|
52
|
+
|
|
53
|
+
mk fail_sh_trap_not_exit
|
|
54
|
+
printf '#!/usr/bin/env bash\nT=$(mktemp)\ntrap '"'"'echo x'"'"' INT\n' > "$T/fail_sh_trap_not_exit/scripts/c.test.sh"
|
|
55
|
+
run_case fail_sh_trap_not_exit 1 "c.test.sh"
|
|
56
|
+
|
|
57
|
+
mk pass_no_temp
|
|
58
|
+
printf 'const x = 1;\n' > "$T/pass_no_temp/bin/tests/d.test.mjs"
|
|
59
|
+
printf '#!/usr/bin/env bash\n# mktemp is only mentioned in a comment\n' > "$T/pass_no_temp/scripts/d.test.sh"
|
|
60
|
+
run_case pass_no_temp 0 "OK"
|
|
61
|
+
|
|
62
|
+
out=$(bash "$LINT" "$T/does-not-exist" 2>&1); rc=$?
|
|
63
|
+
[ "$rc" -eq 2 ] && ok "bad ROOT exits 2" || bad "bad ROOT exit $rc ($out)"
|
|
64
|
+
|
|
65
|
+
out=$(bash "$LINT" 2>&1); rc=$?
|
|
66
|
+
[ "$rc" -eq 0 ] && ok "lint is clean on the real repo tree" || bad "real tree has offenders: $out"
|
|
67
|
+
|
|
68
|
+
echo "passed=$PASS failed=$FAIL"
|
|
69
|
+
[ "$FAIL" -eq 0 ]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
//
|
|
4
|
+
// azure-pipelines.mjs - azure-pipelines.yml -> normalised jobs. pr: triggers, stages/jobs/steps, script/bash steps,
|
|
5
|
+
// a small set of known no-op tasks, $(Var) macros from declared and predefined variables. Templates, deployment jobs,
|
|
6
|
+
// variable groups, container jobs and unknown tasks are deferred-to-ci.
|
|
7
|
+
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import { Unsupported, branchMatches, matchesFilter, jobId, deferred, notApplicable, hostClass, DEPLOY_NAME } from '../lib.mjs';
|
|
10
|
+
import { stepsFromScript, loadDoc, parseSafe, baseJob } from './common.mjs';
|
|
11
|
+
|
|
12
|
+
export const id = 'azure-pipelines';
|
|
13
|
+
export const DEF_RE = /^azure-pipelines\.ya?ml$/;
|
|
14
|
+
const NOOP_TASKS = /^(NodeTool|UsePythonVersion|GoTool|JavaToolInstaller|UseDotNet|Cache|PublishBuildArtifacts|PublishTestResults|PublishCodeCoverageResults|PublishPipelineArtifact)@/;
|
|
15
|
+
|
|
16
|
+
function varMap(v) {
|
|
17
|
+
const m = {}; if (!v) return m;
|
|
18
|
+
if (Array.isArray(v)) { for (const x of v) { if (x.group) throw new Unsupported('variable group'); if (x.template) throw new Unsupported('variable template'); if (x.name) m[x.name] = String(x.value ?? ''); } } else for (const [k, x] of Object.entries(v)) m[k] = String(x && typeof x === 'object' ? x.value ?? '' : x);
|
|
19
|
+
return m;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function expandMacros(text, vars) {
|
|
23
|
+
if (/\$\{\{/.test(text)) throw new Unsupported('compile-time ${{ }} expression');
|
|
24
|
+
return String(text).replace(/\$\(([A-Za-z0-9_.]+)\)/g, (_, n) => { if (n in vars) return vars[n]; throw new Unsupported(`variable $(${n})`); });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function prOk(doc, a) {
|
|
28
|
+
const pr = doc.pr;
|
|
29
|
+
if (pr === 'none' || pr === false) return 'pr: none';
|
|
30
|
+
if (pr === undefined || pr === null) return '';
|
|
31
|
+
const br = Array.isArray(pr) ? { include: pr } : (pr.branches || {});
|
|
32
|
+
if (br.include && !branchMatches(a.baseBranch, [].concat(br.include))) return `pr branches exclude base ${a.baseBranch}`;
|
|
33
|
+
if (br.exclude && branchMatches(a.baseBranch, [].concat(br.exclude))) return `pr branches exclude base ${a.baseBranch}`;
|
|
34
|
+
if (pr.paths && a.changed.length) {
|
|
35
|
+
const inc = pr.paths.include || []; const exc = pr.paths.exclude || [];
|
|
36
|
+
if (!a.changed.some((f) => (!inc.length || matchesFilter(f, inc)) && !matchesFilter(f, exc.length ? exc : ['\0']))) return 'pr paths select none of the changed files';
|
|
37
|
+
}
|
|
38
|
+
return '';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function condOk(cond) {
|
|
42
|
+
if (cond === undefined) return true;
|
|
43
|
+
const c = String(cond).replace(/\s+/g, '');
|
|
44
|
+
if (/^(succeeded\(\)|succeeded\('[^']*'\)|always\(\)|succeededOrFailed\(\))$/.test(c)) return true;
|
|
45
|
+
if (/^eq\(variables\['Build\.Reason'\],'PullRequest'\)$/.test(c)) return true;
|
|
46
|
+
if (/^ne\(variables\['Build\.Reason'\],'(Manual|Schedule|IndividualCI|BatchedCI)'\)$/.test(c)) return true;
|
|
47
|
+
if (/^(eq|ne)\(variables\['Build\.Reason'\],/.test(c)) return false;
|
|
48
|
+
throw new Unsupported('condition ' + String(cond).slice(0, 60));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function osOf(pool) { const img = String((pool && (pool.vmImage || pool.name)) || '').toLowerCase(); if (/windows/.test(img)) return 'win32'; if (/mac/.test(img)) return 'darwin'; if (/ubuntu|linux/.test(img)) return 'linux'; return ''; }
|
|
52
|
+
const runnable = (o) => (o === 'linux' ? hostClass() !== 'win32' : o === hostClass());
|
|
53
|
+
|
|
54
|
+
export function plan(a) {
|
|
55
|
+
const out = []; const warnings = []; const used = [];
|
|
56
|
+
for (const file of a.defFiles(DEF_RE)) {
|
|
57
|
+
const doc = loadDoc(a, file, parseSafe);
|
|
58
|
+
if (doc === undefined) { out.push(deferred(jobId('azure', 'pipeline'), 'azure-pipelines', file, 'pipeline YAML unreadable')); continue; }
|
|
59
|
+
if (!doc || typeof doc !== 'object') continue;
|
|
60
|
+
used.push(file);
|
|
61
|
+
const why = prOk(doc, a);
|
|
62
|
+
if (why) { out.push(notApplicable(jobId('azure', 'pipeline'), 'azure-pipelines', file, why)); continue; }
|
|
63
|
+
let gvars;
|
|
64
|
+
try { gvars = varMap(doc.variables); } catch (e) { out.push(deferred(jobId('azure', 'pipeline'), 'azure-pipelines', file, e.message)); continue; }
|
|
65
|
+
const jobs = [];
|
|
66
|
+
if (Array.isArray(doc.stages)) for (const st of doc.stages) for (const j of st.jobs || []) jobs.push({ j, pool: doc.pool, stage: st.stage });
|
|
67
|
+
else if (Array.isArray(doc.jobs)) for (const j of doc.jobs) jobs.push({ j, pool: doc.pool });
|
|
68
|
+
else if (Array.isArray(doc.steps)) jobs.push({ j: { job: 'build', steps: doc.steps }, pool: doc.pool });
|
|
69
|
+
for (const { j, pool, stage } of jobs) {
|
|
70
|
+
const key = j.job || j.deployment || j.template || 'job';
|
|
71
|
+
const jid = jobId('azure', stage || '', key); const name = j.displayName || key;
|
|
72
|
+
const def = (r) => out.push(deferred(jid, 'azure-pipelines', name, r));
|
|
73
|
+
try {
|
|
74
|
+
if (j.template) { def('job template'); continue; }
|
|
75
|
+
if (j.deployment) { def('deployment job'); continue; }
|
|
76
|
+
if (j.container || j.services) { def('container/service job'); continue; }
|
|
77
|
+
if (DEPLOY_NAME.test(key)) { def('deploy/publish job'); continue; }
|
|
78
|
+
if (!condOk(j.condition)) { out.push(notApplicable(jid, 'azure-pipelines', name, 'condition is false for pull requests')); continue; }
|
|
79
|
+
const jvars = { ...gvars, ...varMap(j.variables) };
|
|
80
|
+
const p = j.pool || pool; const o = osOf(p);
|
|
81
|
+
if (!o) { def('unknown agent pool'); continue; }
|
|
82
|
+
if (!runnable(o)) { def(`${o} agent (host is ${hostClass()})`); continue; }
|
|
83
|
+
const pre = { 'Build.SourcesDirectory': a.repo, 'Build.Repository.LocalPath': a.repo, 'Build.SourceVersion': a.headSha, 'Build.SourceBranch': `refs/heads/${a.headBranch || ''}`, 'Build.SourceBranchName': a.headBranch || '', 'System.PullRequest.TargetBranch': a.baseBranch, 'System.PullRequest.SourceBranch': a.headBranch || '', 'Build.Reason': 'PullRequest', 'Agent.OS': { linux: 'Linux', darwin: 'Darwin', win32: 'Windows_NT' }[hostClass()], 'Agent.TempDirectory': os.tmpdir(), 'Pipeline.Workspace': a.repo, 'Build.ArtifactStagingDirectory': os.tmpdir() };
|
|
84
|
+
const vars = { ...pre, ...jvars };
|
|
85
|
+
let combos = [{}]; let tag; let variants = 0;
|
|
86
|
+
const mx = j.strategy && j.strategy.matrix;
|
|
87
|
+
if (mx && typeof mx === 'object') { const ks = Object.keys(mx); combos = ks.map((k) => ({ __n: k, ...mx[k] })); variants = combos.length - 1; tag = ks[0]; }
|
|
88
|
+
const env = {}; for (const [k, v] of Object.entries(combos[0])) if (k !== '__n') { env[k] = String(v); vars[k] = String(v); }
|
|
89
|
+
const steps = []; let defer = '';
|
|
90
|
+
for (const s of j.steps || []) {
|
|
91
|
+
if (s.condition !== undefined && !condOk(s.condition)) continue;
|
|
92
|
+
if (s.template || s.download || s.getPackage) { defer = defer || 'step template/download'; break; }
|
|
93
|
+
if (s.checkout !== undefined) continue;
|
|
94
|
+
if (s.task) {
|
|
95
|
+
if (NOOP_TASKS.test(s.task)) continue;
|
|
96
|
+
if (/^Npm@1$/.test(s.task) && s.inputs && ['ci', 'install'].includes(s.inputs.command || 'install')) { steps.push({ name: s.displayName || 'npm ' + (s.inputs.command || 'install'), command: 'npm ' + (s.inputs.command || 'install'), cwd: '.', env: {}, kind: 'install' }); continue; }
|
|
97
|
+
defer = defer || `unknown task ${s.task}`; break;
|
|
98
|
+
}
|
|
99
|
+
const body = s.script ?? s.bash;
|
|
100
|
+
if (body === undefined) { defer = defer || (s.pwsh || s.powershell ? 'PowerShell step' : 'unsupported step'); break; }
|
|
101
|
+
const sh = s.bash !== undefined ? 'bash' : undefined;
|
|
102
|
+
const r = stepsFromScript(expandMacros(body, vars), { name: s.displayName || 'script', cwd: s.workingDirectory ? expandMacros(s.workingDirectory, vars) : '.', env: Object.fromEntries(Object.entries(s.env || {}).map(([k, v]) => [k, expandMacros(v, vars)])), shell: sh, repo: a.repo, continue_on_error: s.continueOnError === true });
|
|
103
|
+
if (r.defer) { defer = defer || r.defer; break; }
|
|
104
|
+
steps.push(...r.steps);
|
|
105
|
+
}
|
|
106
|
+
if (defer) { def(defer); continue; }
|
|
107
|
+
out.push(baseJob({ id: jid, source: 'azure-pipelines', name, env: { ...env, BUILD_SOURCESDIRECTORY: a.repo }, steps, needs: [].concat(j.dependsOn || []).map((d) => jobId('azure', stage || '', d)), matrix: tag, timeout_s: j.timeoutInMinutes ? Number(j.timeoutInMinutes) * 60 : undefined }));
|
|
108
|
+
if (variants) out.push(deferred(jobId(jid, 'variants'), 'azure-pipelines', name, `${variants} matrix variant(s) not run locally`));
|
|
109
|
+
} catch (e) { if (e instanceof Unsupported) { def(`unsupported construct: ${e.message}`); continue; } throw e; }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { jobs: out, warnings, used };
|
|
113
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
//
|
|
4
|
+
// bitbucket.mjs - bitbucket-pipelines.yml -> normalised jobs. Pull-request (else default) pipeline, step/parallel/stage
|
|
5
|
+
// items, condition.changesets path filters, definition services on host networking. Pipes, deployments, manual
|
|
6
|
+
// triggers, OIDC and credential-like variables are deferred-to-ci.
|
|
7
|
+
|
|
8
|
+
import { Unsupported, branchMatches, matchesFilter, jobId, deferred, notApplicable, secretLikeVar, hostClass, DEPLOY_NAME } from '../lib.mjs';
|
|
9
|
+
import { stepsFromScript, loadDoc, parseSafe, baseJob, globList } from './common.mjs';
|
|
10
|
+
|
|
11
|
+
export const id = 'bitbucket';
|
|
12
|
+
export const DEF_RE = /^bitbucket-pipelines\.ya?ml$/;
|
|
13
|
+
|
|
14
|
+
function flatten(items, parallel = false, out = []) {
|
|
15
|
+
for (const it of items || []) {
|
|
16
|
+
if (it.step) out.push({ step: it.step, parallel });
|
|
17
|
+
else if (it.parallel) flatten(Array.isArray(it.parallel) ? it.parallel : it.parallel.steps, true, out);
|
|
18
|
+
else if (it.stage) flatten(it.stage.steps, parallel, out);
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function plan(a) {
|
|
24
|
+
const out = []; const warnings = []; const used = [];
|
|
25
|
+
for (const file of a.defFiles(DEF_RE)) {
|
|
26
|
+
const doc = loadDoc(a, file, parseSafe);
|
|
27
|
+
if (doc === undefined) { out.push(deferred(jobId('bitbucket', 'pipeline'), 'bitbucket', file, 'pipeline YAML unreadable')); continue; }
|
|
28
|
+
if (!doc || !doc.pipelines) continue;
|
|
29
|
+
used.push(file);
|
|
30
|
+
let items = null; let which = 'default';
|
|
31
|
+
const pr = doc.pipelines['pull-requests'];
|
|
32
|
+
if (pr) for (const [pat, v] of Object.entries(pr)) if (branchMatches(a.headBranch || '', [pat])) { items = v; which = `pull-requests:${pat}`; break; }
|
|
33
|
+
if (!items && Array.isArray(doc.pipelines.default)) items = doc.pipelines.default;
|
|
34
|
+
if (!items) { out.push(notApplicable(jobId('bitbucket', 'pipeline'), 'bitbucket', file, 'no pull-request or default pipeline matches this branch')); continue; }
|
|
35
|
+
const steps = flatten(items); let prev = []; let prevIds = [];
|
|
36
|
+
steps.forEach((entry, idx) => {
|
|
37
|
+
const s = entry.step; const name = s.name || `step-${idx + 1}`; const jid = jobId('bitbucket', which, String(idx + 1), name);
|
|
38
|
+
const def = (r) => out.push(deferred(jid, 'bitbucket', name, r));
|
|
39
|
+
const needs = entry.parallel ? prev : prevIds;
|
|
40
|
+
const next = () => { if (!entry.parallel) { prevIds = [jid]; prev = [jid]; } else prev = prev.concat([jid]); };
|
|
41
|
+
try {
|
|
42
|
+
if (s.trigger === 'manual') { def('manual trigger'); next(); return; }
|
|
43
|
+
if (s.deployment || DEPLOY_NAME.test(name)) { def('deployment step'); next(); return; }
|
|
44
|
+
if (s.oidc) { def('OIDC token'); next(); return; }
|
|
45
|
+
if (s['runs-on']) { def('self-hosted runner'); next(); return; }
|
|
46
|
+
const inc = s.condition && s.condition.changesets && globList(s.condition.changesets.includePaths);
|
|
47
|
+
if (inc && inc.length && a.changed.length && !a.changed.some((f) => matchesFilter(f, inc))) { out.push(notApplicable(jid, 'bitbucket', name, 'condition.changesets selects none of the changed files')); next(); return; }
|
|
48
|
+
const script = s.script || [];
|
|
49
|
+
if (script.some((l) => typeof l !== 'string')) { def('script uses a pipe'); next(); return; }
|
|
50
|
+
const svcNames = (s.services || []).filter((x) => x !== 'docker');
|
|
51
|
+
const services = svcNames.map((n) => { const d = doc.definitions && doc.definitions.services && doc.definitions.services[n]; if (!d || !d.image) throw new Unsupported(`service ${n}`); return { name: n, image: typeof d.image === 'string' ? d.image : d.image.name, env: d.variables || {}, ports: [], options: '', host_network: true }; });
|
|
52
|
+
if (services.length && hostClass() !== 'linux') { def('service containers need host networking (Linux only)'); next(); return; }
|
|
53
|
+
const sv = secretLikeVar(script.join('\n'));
|
|
54
|
+
if (sv) { def(`references credential-like variable $${sv}`); next(); return; }
|
|
55
|
+
const r = stepsFromScript(script.join('\n'), { name, cwd: '.', env: {}, repo: a.repo });
|
|
56
|
+
if (r.defer) { def(r.defer); next(); return; }
|
|
57
|
+
const env = { BITBUCKET_COMMIT: a.headSha, BITBUCKET_BRANCH: a.headBranch || '', BITBUCKET_PR_DESTINATION_BRANCH: a.baseBranch, BITBUCKET_CLONE_DIR: a.repo };
|
|
58
|
+
out.push(baseJob({ id: jid, source: 'bitbucket', name, env, steps: r.steps, services, needs, timeout_s: s['max-time'] ? Number(s['max-time']) * 60 : undefined, warnings: s.image ? [`image ${typeof s.image === 'string' ? s.image : s.image.name} ignored; running with the host toolchain`] : [] }));
|
|
59
|
+
next();
|
|
60
|
+
} catch (e) { if (e instanceof Unsupported) { def(`unsupported construct: ${e.message}`); next(); return; } throw e; }
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return { jobs: out, warnings, used };
|
|
64
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
//
|
|
4
|
+
// buildkite.mjs - .buildkite/pipeline.yml -> normalised jobs. Command steps, wait barriers, groups, branch filters and
|
|
5
|
+
// simple `if:` conditions. Plugins, block/input/trigger steps, buildkite-agent calls, parallelism/matrix and
|
|
6
|
+
// credential-like variables are deferred-to-ci.
|
|
7
|
+
|
|
8
|
+
import { Unsupported, evalExpr, branchMatches, jobId, deferred, notApplicable, secretLikeVar, hostClass, DEPLOY_NAME } from '../lib.mjs';
|
|
9
|
+
import { stepsFromScript, loadDoc, parseSafe, baseJob } from './common.mjs';
|
|
10
|
+
|
|
11
|
+
export const id = 'buildkite';
|
|
12
|
+
export const DEF_RE = /^(\.buildkite\/pipeline\.ya?ml|buildkite\.ya?ml)$/;
|
|
13
|
+
|
|
14
|
+
function flat(steps, out = []) { for (const s of steps || []) { if (s && s.group) flat(s.steps, out); else out.push(s); } return out; }
|
|
15
|
+
|
|
16
|
+
export function plan(a) {
|
|
17
|
+
const out = []; const warnings = []; const used = [];
|
|
18
|
+
for (const file of a.defFiles(DEF_RE)) {
|
|
19
|
+
const doc = loadDoc(a, file, parseSafe);
|
|
20
|
+
if (doc === undefined) { out.push(deferred(jobId('buildkite', 'pipeline'), 'buildkite', file, 'pipeline YAML unreadable')); continue; }
|
|
21
|
+
if (!doc || !Array.isArray(doc.steps)) continue;
|
|
22
|
+
used.push(file);
|
|
23
|
+
const roots = { build: { branch: a.headBranch || '', source: 'webhook', pull_request: { id: '1', base_branch: a.baseBranch }, tag: '', message: '', commit: a.headSha }, pipeline: { slug: 'local' } };
|
|
24
|
+
const keys = {}; let barrier = []; const emitted = [];
|
|
25
|
+
const items = flat(doc.steps);
|
|
26
|
+
items.forEach((s, idx) => {
|
|
27
|
+
if (typeof s === 'string' || (s && (s.wait !== undefined || s.waiter !== undefined))) { barrier = emitted.slice(); return; }
|
|
28
|
+
if (!s) return;
|
|
29
|
+
const label = String(s.label || s.name || (typeof s.block === 'string' ? s.block : '') || (typeof s.input === 'string' ? s.input : '') || s.key || `step-${idx + 1}`); const jid = jobId('buildkite', s.key || String(idx + 1), label);
|
|
30
|
+
if (s.key) keys[s.key] = jid;
|
|
31
|
+
const def = (r) => { out.push(deferred(jid, 'buildkite', label, r)); emitted.push(jid); };
|
|
32
|
+
try {
|
|
33
|
+
if (s.block || s.input || s.trigger) { def('block/input/trigger step'); return; }
|
|
34
|
+
if (s.command === undefined && s.commands === undefined) return;
|
|
35
|
+
if (s.skip) { out.push(notApplicable(jid, 'buildkite', label, 'skip is set')); return; }
|
|
36
|
+
if (s.branches && !branchMatches(a.headBranch || '', String(s.branches).split(/\s+/).filter(Boolean))) { out.push(notApplicable(jid, 'buildkite', label, 'branches filter excludes this branch')); return; }
|
|
37
|
+
if (s.if !== undefined && !Boolean(evalExpr(String(s.if).replace(/"([^"]*)"/g, "'$1'"), { __roots: roots }))) { out.push(notApplicable(jid, 'buildkite', label, 'if: is false')); return; }
|
|
38
|
+
if (s.plugins && (Array.isArray(s.plugins) ? s.plugins.length : Object.keys(s.plugins).length)) { def('uses plugins (docker/containers)'); return; }
|
|
39
|
+
const ag = s.agents || {}; const agOs = String(Array.isArray(ag) ? '' : ag.os || '').toLowerCase();
|
|
40
|
+
if (agOs && agOs !== hostClass() && !(agOs === 'linux' && hostClass() === 'darwin')) { def(`agent os=${agOs}`); return; }
|
|
41
|
+
if (s.parallelism || s.matrix) { def('parallelism/matrix step'); return; }
|
|
42
|
+
if (DEPLOY_NAME.test(label)) { def('deploy/publish step'); return; }
|
|
43
|
+
const cmds = [].concat(s.commands || s.command).map(String);
|
|
44
|
+
if (cmds.some((c) => /buildkite-agent/.test(c))) { def('uses buildkite-agent'); return; }
|
|
45
|
+
const env = Object.fromEntries(Object.entries(s.env || {}).map(([k, v]) => [k, String(v)]));
|
|
46
|
+
const sv = secretLikeVar(cmds.join('\n'), new Set(Object.keys(env)));
|
|
47
|
+
if (sv) { def(`references credential-like variable $${sv}`); return; }
|
|
48
|
+
const r = stepsFromScript(cmds.join('\n'), { name: label, cwd: '.', env: {}, repo: a.repo, continue_on_error: s.soft_fail === true, timeout_s: s.timeout_in_minutes ? Number(s.timeout_in_minutes) * 60 : undefined });
|
|
49
|
+
if (r.defer) { def(r.defer); return; }
|
|
50
|
+
const needs = (s.depends_on !== undefined ? [].concat(s.depends_on).map((k) => keys[typeof k === 'string' ? k : k && k.step]).filter(Boolean) : barrier);
|
|
51
|
+
Object.assign(env, { BUILDKITE_BRANCH: a.headBranch || '', BUILDKITE_COMMIT: a.headSha, BUILDKITE_BUILD_CHECKOUT_PATH: a.repo, BUILDKITE_PULL_REQUEST_BASE_BRANCH: a.baseBranch });
|
|
52
|
+
out.push(baseJob({ id: jid, source: 'buildkite', name: label, env, steps: r.steps, needs, timeout_s: s.timeout_in_minutes ? Number(s.timeout_in_minutes) * 60 : undefined }));
|
|
53
|
+
emitted.push(jid);
|
|
54
|
+
} catch (e) { if (e instanceof Unsupported) { def(`unsupported construct: ${e.message}`); return; } throw e; }
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return { jobs: out, warnings, used };
|
|
58
|
+
}
|