@plainconceptsplatform/workflows 1.20.1 → 1.21.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.
@@ -34,6 +34,12 @@ if [ "$current" != "$HEAD_SHA" ]; then
34
34
  out ""
35
35
  exit 0
36
36
  fi
37
+ reason=$(bash "$(dirname "${BASH_SOURCE[0]}")/side-checks.sh" "$REPO" "$HEAD_SHA")
38
+ if [ -n "$reason" ]; then
39
+ echo "::warning::Not landing #$PR: $reason."
40
+ out ""
41
+ exit 0
42
+ fi
37
43
  base=$(jq -r .baseRefName <<<"$pr_json")
38
44
  head_ref=$(jq -r .headRefName <<<"$pr_json")
39
45
 
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env bash
2
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/land-pull-request/side-checks.sh. Update with workflows update --force; consumer edits may be overwritten.
3
+ #
4
+ # Prints why a commit may not land yet, or nothing when it may. A side workflow
5
+ # (SIDE_CHECK_WORKFLOWS, comma-separated, default "Agentics checks") runs only when its paths change,
6
+ # so no ruleset can require it, and the CI verdict the train and the gate read leaves it out: a
7
+ # release once merged into three apps with its workflow lint red. A side workflow that did not run
8
+ # on the commit is no objection; one that is running, failed or cannot be read is.
9
+ # Usage: side-checks.sh <owner/repo> <sha>
10
+ set -euo pipefail
11
+ repo="$1"
12
+ sha="$2"
13
+ IFS=',' read -ra names <<<"${SIDE_CHECK_WORKFLOWS-Agentics checks}"
14
+ [ "${#names[@]}" -gt 0 ] || exit 0
15
+
16
+ if ! runs=$(gh api "repos/$repo/actions/runs?head_sha=$sha&per_page=100" --jq '.workflow_runs' 2>/dev/null); then
17
+ echo "the workflow runs on ${sha:0:7} could not be read"
18
+ exit 0
19
+ fi
20
+
21
+ for name in "${names[@]}"; do
22
+ name=$(sed -E 's/^ +| +$//g' <<<"$name")
23
+ [ -n "$name" ] || continue
24
+ latest=$(jq -r --arg n "$name" '[.[] | select(.name == $n)] | sort_by(.created_at) | last // empty | "\(.status) \(.conclusion // "")"' <<<"$runs")
25
+ [ -n "$latest" ] || continue
26
+ status=${latest%% *}
27
+ conclusion=${latest#* }
28
+ if [ "$status" != completed ]; then
29
+ echo "$name is still running on ${sha:0:7}"
30
+ exit 0
31
+ fi
32
+ case "$conclusion" in
33
+ success | skipped | neutral) ;;
34
+ *) echo "$name ended ${conclusion:-without a conclusion} on ${sha:0:7}"; exit 0 ;;
35
+ esac
36
+ done
@@ -34,6 +34,12 @@ runs:
34
34
 
35
35
  HEAD_REF=$(gh pr view "$PR_NUM" --repo "$REPO" --json headRefName --jq '.headRefName')
36
36
 
37
+ reason=$(bash ./.github/actions/land-pull-request/side-checks.sh "$REPO" "$HEAD_SHA")
38
+ if [ -n "$reason" ]; then
39
+ echo "::warning::Not merging #$PR_NUM: $reason."
40
+ continue
41
+ fi
42
+
37
43
  gh pr merge "$PR_NUM" --repo "$REPO" --squash --match-head-commit "$HEAD_SHA"
38
44
 
39
45
  # Delete the branch, because nothing else ever has: four repositories had accumulated
@@ -520,6 +520,17 @@ for line in "${candidates[@]}"; do
520
520
  [ $((now_epoch - $(iso_epoch "$ci_created"))) -lt $((STUCK_HOURS * 3600)) ] || ci_stuck=true
521
521
  fi
522
522
 
523
+ # A green CI run is not the whole verdict: a side workflow (Agentics checks) on the same head may be
524
+ # red or still running, and the gate could then never land it. Held until that changes.
525
+ if [ "$ci_status" = completed ] && [ "$ci_conclusion" = success ] && [ "$park" = none ]; then
526
+ side=$(bash "$(dirname "${BASH_SOURCE[0]}")/../land-pull-request/side-checks.sh" "$REPO" "$head_sha")
527
+ if [ -n "$side" ]; then
528
+ echo "#$pr ($author): hold, $side."
529
+ summary "| #$pr | $author | hold | $side |"
530
+ continue
531
+ fi
532
+ fi
533
+
523
534
  mergeable=UNKNOWN
524
535
  behind_by=0
525
536
  if [ "$park" = none ] && [ "$hold" != true ] && [ "$review_decision" != CHANGES_REQUESTED ]; then
@@ -1934,6 +1934,15 @@ fi
1934
1934
  if worker_installed implement && ! grep -qF 'subagent_type: planner-sub' "$IMPLEMENT_WORKER_MD"; then
1935
1935
  LEAD_OK=0; echo "FAIL: the implement prompt must run propose and archive in the planner-sub agent" >&2
1936
1936
  fi
1937
+ # agent-harness before 3.11.3 writes `resource: *`, a YAML alias that does not parse: opencode drops the agent's
1938
+ # mode and refuses to spawn planner and planner-sub ("cannot run as a subagent"). The CI setup repairs the
1939
+ # generated files before opencode reads them, and the implement prompt stops at the first spawn error.
1940
+ if ! grep -qF 'resource: "*"' "${WORKFLOWS_DIR}/shared/opencode-ci.md"; then
1941
+ LEAD_OK=0; echo "FAIL: the CI setup must quote the generated agents' permission resource (a bare * is a YAML alias)" >&2
1942
+ fi
1943
+ if worker_installed implement && ! grep -qF 'cannot run as a subagent' "$IMPLEMENT_WORKER_MD"; then
1944
+ LEAD_OK=0; echo "FAIL: the implement prompt must stop at a planner-sub spawn error instead of retrying other agents" >&2
1945
+ fi
1937
1946
  if [ "$LEAD_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
1938
1947
  # The harness plugins only load with .opencode/node_modules installed; without it every CI run
1939
1948
  # spawned untiered workers on the session's model. And the tier model a worker pins has to be one
@@ -3699,6 +3708,31 @@ if [ -f "$RTP_YML" ] && worker_installed release; then
3699
3708
  if [ "$RTP_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
3700
3709
  fi
3701
3710
 
3711
+ echo "── Required checks and side workflows ────────────────────────────────────"
3712
+
3713
+ # The main ruleset requires one check, Full CI, so Full CI must need every blocking job, and the
3714
+ # side workflows no ruleset can require (Agentics checks runs only on its paths) must be checked
3715
+ # by both merge points and held by the train. Dropping any one of these lets a red job merge.
3716
+ RC_OK=1
3717
+ rc() { grep -qE "$1" "$2" || { RC_OK=0; echo "FAIL: required checks: $3" >&2; }; }
3718
+ CI_DOTNET="${HERE}/../../templates/ci/app-ci-dotnet-next.yml"
3719
+ CI_NODE="${HERE}/../../templates/ci/app-ci-node-monorepo.yml"
3720
+ RULESET="${HERE}/../../templates/rulesets/main-required-checks.json"
3721
+ for job in sast-scan secret-scan deps-and-iac-scan platform-conformance engineering-excellence api web; do
3722
+ awk '/^ full-ci:$/{f=1} f&&/needs:/{print; exit}' "$CI_DOTNET" | grep -qE "[[ ]${job}[],]" || { RC_OK=0; echo "FAIL: required checks: the .NET template's Full CI does not need ${job}" >&2; }
3723
+ done
3724
+ for job in lint sast-scan secret-scan deps-and-iac-scan web; do
3725
+ awk '/^ full-ci:$/{f=1} f&&/needs:/{print; exit}' "$CI_NODE" | grep -qE "[[ ]${job}[],]" || { RC_OK=0; echo "FAIL: required checks: the node template's Full CI does not need ${job}" >&2; }
3726
+ done
3727
+ rc '"context": "Full CI"' "$RULESET" 'the ruleset must require Full CI'
3728
+ rc '"include": \["~DEFAULT_BRANCH"\]' "$RULESET" 'the ruleset must target the default branch'
3729
+ rc 'side-checks\.sh' "${HERE}/../land-pull-request/land-pull-request.sh" 'land-pull-request must check the side workflows'
3730
+ rc 'side-checks\.sh' "${HERE}/../merge-agent-pr/action.yml" 'merge-agent-pr must check the side workflows'
3731
+ rc 'land-pull-request/side-checks\.sh' "${HERE}/../merge-train/merge-train.sh" 'the merge train must hold a pull request whose side workflow is red'
3732
+ rc 'test-timings\.mjs' "$CI_DOTNET" 'the .NET template must summarise the test timings'
3733
+ [ "$(grep -c -- '--logger trx' "$CI_DOTNET")" -ge 5 ] || { RC_OK=0; echo "FAIL: required checks: every .NET test step must write a trx report" >&2; }
3734
+ if [ "$RC_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
3735
+
3702
3736
  echo "── App error privacy ─────────────────────────────────────────────────────"
3703
3737
 
3704
3738
  # The application error report files into the private repository rather than out of it, so
@@ -214,19 +214,19 @@ jobs:
214
214
  - name: Unit tests + coverage
215
215
  if: env.UNIT_TEST_PROJECT != ''
216
216
  run: dotnet test "$UNIT_TEST_PROJECT" --no-build -c Release
217
- --collect:"XPlat Code Coverage" --settings "$COVERAGE_SETTINGS" --results-directory TestResults
217
+ --collect:"XPlat Code Coverage" --settings "$COVERAGE_SETTINGS" --results-directory TestResults --logger trx
218
218
  - name: Parity tests
219
219
  if: env.PARITY_TEST_PROJECT != ''
220
- run: dotnet test "$PARITY_TEST_PROJECT" --no-build -c Release
220
+ run: dotnet test "$PARITY_TEST_PROJECT" --no-build -c Release --results-directory TestResults --logger trx
221
221
  - name: Architecture tests
222
222
  if: env.ARCHITECTURE_TEST_PROJECT != ''
223
- run: dotnet test "$ARCHITECTURE_TEST_PROJECT" --no-build -c Release
223
+ run: dotnet test "$ARCHITECTURE_TEST_PROJECT" --no-build -c Release --results-directory TestResults --logger trx
224
224
  # Executable OpenSpec scenarios (Reqnroll). Each scenario is bound to a spec scenario by tag
225
225
  # and fails if that scenario disappears from openspec/.
226
226
  - name: Acceptance tests (Reqnroll) + coverage
227
227
  if: env.ACCEPTANCE_TEST_PROJECT != ''
228
228
  run: dotnet test "$ACCEPTANCE_TEST_PROJECT" --no-build -c Release
229
- --collect:"XPlat Code Coverage" --settings "$COVERAGE_SETTINGS" --results-directory TestResults
229
+ --collect:"XPlat Code Coverage" --settings "$COVERAGE_SETTINGS" --results-directory TestResults --logger trx
230
230
  # APP_SQL_SERVER is the connection string without a database; each later step names its own.
231
231
  - name: Create test databases
232
232
  env:
@@ -259,11 +259,16 @@ jobs:
259
259
  export "$INTEGRATION_CONNECTION_ENV=$APP_SQL_SERVER;Database=$PRIMARY_DATABASE"
260
260
  for pair in $EXTRA_DATABASES; do export "${pair%%=*}=$APP_SQL_SERVER;Database=${pair#*=}"; done
261
261
  dotnet test "$INTEGRATION_TEST_PROJECT" --no-build -c Release \
262
- --collect:"XPlat Code Coverage" --settings "$COVERAGE_SETTINGS" --results-directory TestResults
262
+ --collect:"XPlat Code Coverage" --settings "$COVERAGE_SETTINGS" --results-directory TestResults --logger trx
263
263
  # The coverage step runs a small node script; fresh runner VMs ship no node.
264
264
  - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
265
265
  with:
266
266
  node-version: ${{ env.NODE_VERSION }}
267
+ # Where the run's time went, by test class: a class is what xUnit runs in parallel and where a
268
+ # fixture's setup is paid, so a slow class shows up the day it is added. Never fails the job.
269
+ - name: Test timings
270
+ if: ${{ !cancelled() }}
271
+ run: node "$GITHUB_WORKSPACE/scripts/test-timings.mjs" TestResults >> "$GITHUB_STEP_SUMMARY" || true
267
272
  # pipefail: without it the step reports tee's status and passes however low coverage is.
268
273
  - name: Coverage floor (merged)
269
274
  run: |
@@ -854,20 +859,22 @@ jobs:
854
859
  retention-days: 90
855
860
 
856
861
  # The merge train reads this job by its name to tell a full run from a light one: do not rename it.
862
+ # It is also the check the main ruleset requires (loops/templates/rulesets), so it needs every
863
+ # blocking job, the scans as well as the heavy ones: a red scan must stop a merge too.
857
864
  full-ci:
858
865
  name: Full CI
859
- needs: [depth, api, web, e2e, engineering-excellence]
866
+ needs: [depth, api, web, e2e, engineering-excellence, markdown, repo-checks, platform-conformance, secret-scan, deps-and-iac-scan, sast-scan]
860
867
  if: ${{ always() && needs.depth.outputs.full == 'true' }}
861
868
  runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
862
869
  timeout-minutes: 5
863
870
  steps:
864
- - name: Every heavy job passed
871
+ - name: Every blocking job passed
865
872
  env:
866
873
  RESULTS: ${{ join(needs.*.result, ' ') }}
867
874
  run: |
868
875
  for result in $RESULTS; do
869
876
  case "$result" in
870
- failure | cancelled | timed_out) echo "A heavy job ended $result."; exit 1 ;;
877
+ failure | cancelled | timed_out) echo "A blocking job ended $result."; exit 1 ;;
871
878
  esac
872
879
  done
873
880
  echo "Full CI passed."
@@ -209,21 +209,22 @@ jobs:
209
209
  # Same guard as above: a tag release needs contents: write, which this job lacks.
210
210
  upload-release-assets: false
211
211
 
212
- # The merge queue requires this check. On a pull request with MERGE_QUEUE on, the heavy jobs are
212
+ # The merge queue and the main ruleset (loops/templates/rulesets) require this check, so it needs
213
+ # every blocking job, the scans too. On a pull request with MERGE_QUEUE on, the heavy jobs are
213
214
  # skipped and the full CI runs on the merge group instead; skipped counts as passed.
214
215
  full-ci:
215
216
  name: Full CI
216
- needs: [web, e2e, desktop, mobile]
217
+ needs: [lint, web, e2e, desktop, mobile, secret-scan, deps-and-iac-scan, sast-scan]
217
218
  if: always()
218
219
  runs-on: agents-arc
219
220
  timeout-minutes: 5
220
221
  steps:
221
- - name: Every heavy job passed
222
+ - name: Every blocking job passed
222
223
  env:
223
224
  RESULTS: ${{ join(needs.*.result, ' ') }}
224
225
  run: |
225
226
  for result in $RESULTS; do
226
- case "$result" in failure | cancelled | timed_out) echo "A heavy job ended $result."; exit 1 ;; esac
227
+ case "$result" in failure | cancelled | timed_out) echo "A blocking job ended $result."; exit 1 ;; esac
227
228
  done
228
229
 
229
230
  # Hands this run's verdict to the router's merge train, which decides whether this pull request
@@ -0,0 +1,74 @@
1
+ // Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/scripts/lib/test-timings.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
2
+
3
+ // Reads VSTest .trx reports and says where a test run's time goes: by test class, since a class is
4
+ // what xUnit runs in parallel and where a fixture's setup is paid once.
5
+
6
+ const attr = (tag, name) => tag.match(new RegExp(`\\b${name}="([^"]*)"`))?.[1];
7
+
8
+ function seconds(duration) {
9
+ const [h, m, s] = (duration ?? '0:0:0').split(':');
10
+ return Number(h) * 3600 + Number(m) * 60 + Number(s);
11
+ }
12
+
13
+ export function parseTrx(xml) {
14
+ const classes = new Map();
15
+ for (const [, id, body] of xml.matchAll(/<UnitTest\b[^>]*\bid="([^"]+)"[^>]*>([\s\S]*?)<\/UnitTest>/g)) {
16
+ const method = body.match(/<TestMethod\b[^>]*>/)?.[0];
17
+ if (method) classes.set(id, attr(method, 'className'));
18
+ }
19
+ const results = [];
20
+ for (const [tag] of xml.matchAll(/<UnitTestResult\b[^>]*>/g)) {
21
+ const className = classes.get(attr(tag, 'testId')) ?? 'unknown';
22
+ results.push({
23
+ className: className.split('.').pop(),
24
+ name: (attr(tag, 'testName') ?? '').replace(`${className}.`, ''),
25
+ seconds: seconds(attr(tag, 'duration')),
26
+ start: Date.parse(attr(tag, 'startTime') ?? ''),
27
+ end: Date.parse(attr(tag, 'endTime') ?? ''),
28
+ outcome: attr(tag, 'outcome') ?? 'Unknown',
29
+ });
30
+ }
31
+ const times = xml.match(/<Times\b[^>]*>/)?.[0] ?? '';
32
+ const wall = (Date.parse(attr(times, 'finish') ?? '') - Date.parse(attr(times, 'start') ?? '')) / 1000;
33
+ const runName = xml.match(/<TestRun\b[^>]*>/)?.[0];
34
+ const storage = xml.match(/<UnitTest\b[^>]*\bstorage="([^"]+)"/)?.[1] ?? attr(runName ?? '', 'name') ?? 'tests';
35
+ return { name: storage.split(/[\\/]/).pop().replace(/\.dll$/i, ''), wall: Number.isFinite(wall) ? wall : 0, results };
36
+ }
37
+
38
+ export function summarize(run, top = 15) {
39
+ const byClass = new Map();
40
+ for (const r of run.results) {
41
+ const c = byClass.get(r.className) ?? { name: r.className, tests: 0, seconds: 0, first: null };
42
+ c.tests += 1;
43
+ c.seconds += r.seconds;
44
+ if (!c.first || r.start < c.first.start) c.first = r;
45
+ byClass.set(r.className, c);
46
+ }
47
+ const total = run.results.reduce((sum, r) => sum + r.seconds, 0);
48
+ const classes = [...byClass.values()].sort((a, b) => b.seconds - a.seconds);
49
+ return {
50
+ name: run.name,
51
+ tests: run.results.length,
52
+ wall: run.wall,
53
+ total,
54
+ concurrency: run.wall > 0 ? total / run.wall : 0,
55
+ classes: classes.slice(0, top).map(({ name, tests, seconds, first }) => ({ name, tests, seconds, firstTest: first?.seconds ?? 0 })),
56
+ slowest: [...run.results].sort((a, b) => b.seconds - a.seconds).slice(0, top).map(({ className, name, seconds }) => ({ className, name, seconds })),
57
+ };
58
+ }
59
+
60
+ const s = (n) => `${n.toFixed(1)} s`;
61
+
62
+ export function toMarkdown(summaries) {
63
+ const lines = ['### Test timings', ''];
64
+ for (const t of summaries) {
65
+ lines.push(`**${t.name}**: ${t.tests} tests, ${s(t.wall)} wall, ${s(t.total)} of test time, ${t.concurrency.toFixed(1)} running at once on average.`, '');
66
+ if (t.tests < 20) continue;
67
+ lines.push('| Class | Tests | Time | First test |', '|---|--:|--:|--:|');
68
+ for (const c of t.classes) lines.push(`| ${c.name} | ${c.tests} | ${s(c.seconds)} | ${s(c.firstTest)} |`);
69
+ lines.push('', '<details><summary>Slowest tests</summary>', '');
70
+ for (const r of t.slowest) lines.push(`- ${s(r.seconds)} ${r.className}: ${r.name.replace(/\|/g, '\\|')}`);
71
+ lines.push('', '</details>', '');
72
+ }
73
+ return lines.join('\n');
74
+ }
@@ -0,0 +1,46 @@
1
+ // Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/scripts/lib/test-timings.test.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ // Run: node --test scripts/lib/test-timings.test.mjs
3
+ import assert from 'node:assert/strict';
4
+ import { test } from 'node:test';
5
+ import { parseTrx, summarize, toMarkdown } from './test-timings.mjs';
6
+
7
+ const unit = (id, cls, name) =>
8
+ `<UnitTest name="${name}" storage="/w/App.IntegrationTests.dll" id="${id}"><Execution id="e${id}" /><TestMethod codeBase="x" className="App.Tests.${cls}" name="${name}" /></UnitTest>`;
9
+ const result = (id, duration, start, end) =>
10
+ `<UnitTestResult executionId="e${id}" testId="${id}" testName="${id} name" duration="${duration}" startTime="${start}" endTime="${end}" outcome="Passed" />`;
11
+
12
+ const trx = `<?xml version="1.0"?><TestRun id="r" name="run">
13
+ <Times creation="2026-01-01T00:00:00Z" start="2026-01-01T00:00:00.000Z" finish="2026-01-01T00:00:10.000Z" />
14
+ <Results>
15
+ ${result('a', '00:00:06.0000000', '2026-01-01T00:00:00Z', '2026-01-01T00:00:06Z')}
16
+ ${result('b', '00:00:01.0000000', '2026-01-01T00:00:06Z', '2026-01-01T00:00:07Z')}
17
+ ${result('c', '00:00:08.0000000', '2026-01-01T00:00:01Z', '2026-01-01T00:00:09Z')}
18
+ </Results>
19
+ <TestDefinitions>${unit('a', 'OrdersTests', 'First')}${unit('b', 'OrdersTests', 'Second')}${unit('c', 'StockTests', 'Only')}</TestDefinitions>
20
+ </TestRun>`;
21
+
22
+ test('parseTrx_resultsAndDefinitions_joinsClassAndDurationAndWallTime', () => {
23
+ const run = parseTrx(trx);
24
+ assert.equal(run.name, 'App.IntegrationTests');
25
+ assert.equal(run.wall, 10);
26
+ assert.deepEqual(run.results.map((r) => [r.className, r.seconds]), [['OrdersTests', 6], ['OrdersTests', 1], ['StockTests', 8]]);
27
+ });
28
+
29
+ test('summarize_classesBySummedTime_reportsConcurrencyAndTheFirstTestCost', () => {
30
+ const t = summarize(parseTrx(trx));
31
+ assert.equal(t.total, 15);
32
+ assert.equal(t.concurrency, 1.5);
33
+ assert.deepEqual(t.classes.map((c) => [c.name, c.tests, c.seconds, c.firstTest]), [['StockTests', 1, 8, 8], ['OrdersTests', 2, 7, 6]]);
34
+ assert.equal(t.slowest[0].className, 'StockTests');
35
+ });
36
+
37
+ test('toMarkdown_smallRun_givesTheTotalsWithoutTables', () => {
38
+ const md = toMarkdown([summarize(parseTrx(trx))]);
39
+ assert.match(md, /\*\*App\.IntegrationTests\*\*: 3 tests, 10\.0 s wall, 15\.0 s of test time, 1\.5 running at once on average\./);
40
+ assert.doesNotMatch(md, /\| Class \|/);
41
+ });
42
+
43
+ test('parseTrx_fullyQualifiedTestName_dropsTheClassPrefix', () => {
44
+ const xml = `<TestRun><UnitTestResult testId="x" testName="App.Tests.OrdersTests.Saves(total: 1.5)" duration="00:00:01" outcome="Passed" />${unit('x', 'OrdersTests', 'Saves')}</TestRun>`;
45
+ assert.equal(parseTrx(xml).results[0].name, 'Saves(total: 1.5)');
46
+ });
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ // Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/scripts/test-timings.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
3
+
4
+ // Usage: node scripts/test-timings.mjs <dir-with-trx> [--top 15]
5
+ // Prints where the test run's time went as Markdown, for the job summary. Never fails the job.
6
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { parseTrx, summarize, toMarkdown } from './lib/test-timings.mjs';
9
+
10
+ const [dir = 'TestResults', ...rest] = process.argv.slice(2);
11
+ const top = Number(rest[rest.indexOf('--top') + 1]) || 15;
12
+
13
+ function trxFiles(root) {
14
+ try {
15
+ return readdirSync(root).flatMap((entry) => {
16
+ const full = join(root, entry);
17
+ return statSync(full).isDirectory() ? trxFiles(full) : entry.endsWith('.trx') ? [full] : [];
18
+ });
19
+ } catch {
20
+ return [];
21
+ }
22
+ }
23
+
24
+ const runs = trxFiles(dir).map((file) => summarize(parseTrx(readFileSync(file, 'utf8')), top)).filter((t) => t.tests > 0);
25
+ console.log(runs.length ? toMarkdown(runs.sort((a, b) => b.wall - a.wall)) : `### Test timings\n\nNo .trx reports under ${dir}.`);
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "Default branch: Full CI required",
3
+ "target": "branch",
4
+ "enforcement": "active",
5
+ "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } },
6
+ "rules": [
7
+ {
8
+ "type": "required_status_checks",
9
+ "parameters": {
10
+ "strict_required_status_checks_policy": false,
11
+ "do_not_enforce_on_create": false,
12
+ "required_status_checks": [{ "context": "Full CI" }]
13
+ }
14
+ }
15
+ ],
16
+ "bypass_actors": [{ "actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always" }]
17
+ }
@@ -802,8 +802,11 @@ timeout-minutes: 180
802
802
  (`pc-plan-propose` or `pc-plan-archive`), the issue context path above, the branch, and that it
803
803
  runs in autonomous mode with no one to ask. `planner-sub` runs on the repository's plan model,
804
804
  which these two steps decide the whole change on. Wait for it in the same turn, then continue
805
- with the next phase yourself. If `planner-sub` is not in the task tool's enum, call
806
- `report_incomplete` naming it; do not load `pc-plan-propose` here. Only when the harness is
805
+ with the next phase yourself. If `planner-sub` is not in the task tool's enum, or the task tool
806
+ returns an error when you spawn it (`Agent planner-sub cannot run as a subagent`, `Unknown agent`),
807
+ call `report_incomplete` once with that exact error text and stop: do not retry with `planner` or
808
+ any other agent, and do not load `pc-plan-propose` here. Another attempt fails the same way and
809
+ only spends the run; the cause is the repository's generated agent files, not the issue. Only when the harness is
807
810
  older than 3.4.0 and has neither `planner` nor `planner-sub` may you load the skill here.
808
811
 
809
812
  2. `pc-plan-propose`, even when the issue is refined. It is what turns the issue into
@@ -254,6 +254,20 @@ pre-agent-steps:
254
254
  if [ -f .opencode/scripts/sync-agents.mjs ] && [ -f .opencode/plugins/pc-subagent-tiers-core.js ]; then
255
255
  node .opencode/scripts/sync-agents.mjs --strict
256
256
  fi
257
+ # agent-harness before 3.11.3 writes `resource: *` into plan.md, planner.md and planner-sub.md. A bare `*` is
258
+ # a YAML alias, so the frontmatter does not parse, opencode drops the agents' `mode`, treats them as primary
259
+ # and refuses to spawn them ("Agent planner-sub cannot run as a subagent"). These files are generated here on
260
+ # every run and never committed, so quote the value before opencode reads them; a no-op once the harness does.
261
+ if [ -d .opencode/agents ]; then
262
+ for agent_file in .opencode/agents/*.md; do
263
+ [ -e "$agent_file" ] || continue
264
+ sed -i -E 's/resource:[[:space:]]+[*][[:space:]]*$/resource: "*"/' "$agent_file"
265
+ done
266
+ if grep -nE '^[[:space:]]+resource:[[:space:]]+[*]' .opencode/agents/*.md; then
267
+ echo "::error::A generated agent file still has an unquoted permission resource; opencode cannot parse it." >&2
268
+ exit 1
269
+ fi
270
+ fi
257
271
  if [ -f .opencode/scripts/harness-doctor.mjs ]; then
258
272
  node .opencode/scripts/harness-doctor.mjs --strict
259
273
  fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "1.20.1",
3
+ "version": "1.21.0",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",