@plainconceptsplatform/workflows 1.18.1 → 1.20.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/dist/workflow-catalog.js +1 -1
- package/loops/actions/decide-agent-retry/action.yml +44 -15
- package/loops/actions/decide-agent-retry/classify-failure.sh +47 -2
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +220 -2
- package/loops/docs/diagrams.md +19 -6
- package/loops/workflows/agent-apply-review.md +18 -3
- package/loops/workflows/agent-implement.md +18 -3
- package/loops/workflows/agent-merge-gate.md +4 -2
- package/loops/workflows/agent-refine.md +18 -3
- package/loops/workflows/agent-release.md +13 -0
- package/loops/workflows/agent-triage.md +18 -3
- package/loops/workflows/release-tag.yml +47 -4
- package/loops/workflows/release-to-production.yml +50 -0
- package/loops/workflows/work-router.yml +7 -2
- package/package.json +1 -1
package/dist/workflow-catalog.js
CHANGED
|
@@ -16,7 +16,7 @@ export const workflowRoutes = [
|
|
|
16
16
|
{ name: "merge-gate", worker: "agent-merge-gate.md", description: "Decides what happens to the pull request the merge train hands it, the bot's or a person's: repair it, merge it, or hand it to a human.", defaultEnabled: true },
|
|
17
17
|
{ name: "audit", worker: "agent-audit.md", description: "Read-only repository audit. Finds 5-7 problems, scores each 1-10, and files a tracking report plus one story issue per top finding, each sized for Refine.", defaultEnabled: true },
|
|
18
18
|
{ name: "docs-audit", worker: "agent-docs-audit.md", description: "Read-only documentation audit, every fortnight. Checks the documentation against the code and the Platform standard, and files one issue that Refine then sizes and splits, only when something is stale.", defaultEnabled: false },
|
|
19
|
-
{ name: "release", worker: "agent-release.md", description: "Writes release notes from the commit log, then a deterministic job opens a version-bump pull request with auto-merge; once it merges, release-tag.yml tags the commit and publishes the GitHub Release. Manual dispatch only (operation=release).", defaultEnabled: true, companions: ["release-tag.yml"] },
|
|
19
|
+
{ name: "release", worker: "agent-release.md", description: "Writes release notes from the commit log, then a deterministic job opens a version-bump pull request with auto-merge; once it merges, release-tag.yml tags the commit and publishes the GitHub Release. Manual dispatch only (operation=release), or release-to-production.yml, which also deploys the new tag to production.", defaultEnabled: true, companions: ["release-tag.yml", "release-to-production.yml"] },
|
|
20
20
|
];
|
|
21
21
|
/** What an install with no explicit selection gets. An optional worker is added by name. */
|
|
22
22
|
export const defaultRouteNames = workflowRoutes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/decide-agent-retry/action.yml. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
2
|
name: Decide agent retry
|
|
3
|
-
description: Say whether an agent job that failed is worth running again, from how long it ran
|
|
3
|
+
description: Say whether an agent job that failed is worth running again, from how long it ran, how many attempts have been made, and which step it stopped in.
|
|
4
4
|
inputs:
|
|
5
5
|
token:
|
|
6
6
|
description: GitHub token with actions read access, used to read this run's own jobs.
|
|
@@ -26,8 +26,14 @@ outputs:
|
|
|
26
26
|
description: How long the agent job ran, or -1 when that could not be read.
|
|
27
27
|
value: ${{ steps.decide.outputs.minutes }}
|
|
28
28
|
cause:
|
|
29
|
-
description: 'Why a retry was chosen: short-run, runner-shutdown or cut-off. "none" when there is none.'
|
|
29
|
+
description: 'Why a retry was chosen: short-run, runner-shutdown or cut-off. "configuration" when a preflight step failed and nothing is retried; "cancelled" for a cancelled job; "none" when there is none.'
|
|
30
30
|
value: ${{ steps.decide.outputs.cause }}
|
|
31
|
+
failed-step:
|
|
32
|
+
description: The name of the first step the agent job failed in, or empty.
|
|
33
|
+
value: ${{ steps.decide.outputs.failed-step }}
|
|
34
|
+
reason:
|
|
35
|
+
description: For a configuration failure, one paragraph for the issue naming the step and the fix. Empty otherwise.
|
|
36
|
+
value: ${{ steps.decide.outputs.reason }}
|
|
31
37
|
runs:
|
|
32
38
|
using: composite
|
|
33
39
|
steps:
|
|
@@ -47,6 +53,12 @@ runs:
|
|
|
47
53
|
# apply-review parked instead -- an Odyssey refine died in three minutes on
|
|
48
54
|
# `Model 'glm-5-2' not found`, a gateway fault that clears in seconds, and waited on the
|
|
49
55
|
# janitor's six-hourly sweep. Five copies of sixty lines is how one rule becomes five.
|
|
56
|
+
#
|
|
57
|
+
# And one failure is never repeated, however short: a preflight step (the harness sync and
|
|
58
|
+
# doctor, the CI provider merge) that refused the repository's own setup. It ended a consumer's
|
|
59
|
+
# refine in a minute, which read as a short run, and five attempts were spent and commented as
|
|
60
|
+
# a provider outage when the fix was one missing file (#527). classify-failure.sh names it
|
|
61
|
+
# `configuration` from the failed step's name, and that verdict wins over the duration.
|
|
50
62
|
- name: Decide whether this failure is worth repeating
|
|
51
63
|
id: decide
|
|
52
64
|
shell: bash
|
|
@@ -72,24 +84,39 @@ runs:
|
|
|
72
84
|
[ -n "$attempts" ] || attempts=0
|
|
73
85
|
retry=false
|
|
74
86
|
cause=none
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
87
|
+
failed_step=""
|
|
88
|
+
reason=""
|
|
89
|
+
work="$(mktemp -d)"
|
|
90
|
+
# The reads are best effort: a step list, a log or an annotation list that cannot be
|
|
91
|
+
# fetched leaves the decision to the duration, exactly as before these checks existed.
|
|
92
|
+
if [ -n "${job_id:-}" ]; then
|
|
93
|
+
gh api "repos/$REPO/actions/jobs/$job_id" --jq '.steps[]? | select(.conclusion == "failure") | .name' >"$work/failed-steps" 2>/dev/null || true
|
|
94
|
+
failed_step="$(head -1 "$work/failed-steps" 2>/dev/null || true)"
|
|
79
95
|
fi
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
# as before this check existed.
|
|
83
|
-
if [ "$retry" != true ] && [ "$attempts" -lt "$PARK_AT" ] && [ -n "${job_id:-}" ]; then
|
|
84
|
-
work="$(mktemp -d)"
|
|
96
|
+
kind=none
|
|
97
|
+
if [ -n "${job_id:-}" ] && [ "${conclusion:-}" != cancelled ]; then
|
|
85
98
|
gh api "repos/$REPO/check-runs/$job_id/annotations" --paginate --jq '.[].message' >"$work/annotations" 2>/dev/null || true
|
|
86
99
|
gh api "repos/$REPO/actions/jobs/$job_id/logs" >"$work/log" 2>/dev/null || true
|
|
87
|
-
kind="$(bash "$ACTION_PATH/classify-failure.sh" "$work/log" "$work/annotations" || echo none)"
|
|
88
|
-
|
|
100
|
+
kind="$(bash "$ACTION_PATH/classify-failure.sh" "$work/log" "$work/annotations" "$work/failed-steps" || echo none)"
|
|
101
|
+
fi
|
|
102
|
+
rm -rf "$work"
|
|
103
|
+
if [ "$kind" = configuration ]; then
|
|
104
|
+
# Not a provider outage and not the agent's verdict: the model never started. The next
|
|
105
|
+
# attempt would fail the same way in the same minute, so no attempt is spent on it.
|
|
106
|
+
cause=configuration
|
|
107
|
+
fix="$(bash "$ACTION_PATH/classify-failure.sh" --fix "$failed_step")"
|
|
108
|
+
reason="The agent job stopped in its preflight step **${failed_step}** before the model started. That is this repository's configuration, not a provider outage, so it was not retried. ${fix} Once that is fixed, start the work again."
|
|
109
|
+
else
|
|
110
|
+
# An unknown duration is treated as a long run: never retry on a guess.
|
|
111
|
+
if [ "$minutes" -ge 0 ] && [ "$minutes" -lt "$UNDER_MINUTES" ] && [ "$attempts" -lt "$PARK_AT" ]; then
|
|
112
|
+
retry=true
|
|
113
|
+
cause=short-run
|
|
114
|
+
fi
|
|
115
|
+
# A long run is repeated only when it was killed, not when it was wrong.
|
|
116
|
+
if [ "$retry" != true ] && [ "$attempts" -lt "$PARK_AT" ] && [ "$kind" != none ]; then
|
|
89
117
|
retry=true
|
|
90
118
|
cause="$kind"
|
|
91
119
|
fi
|
|
92
|
-
rm -rf "$work"
|
|
93
120
|
fi
|
|
94
121
|
# A cancelled agent job is a person or a newer run taking the work back, not an outage.
|
|
95
122
|
# Pliny-Bot #816: refine was cancelled by hand and its label removed, then this step read
|
|
@@ -104,5 +131,7 @@ runs:
|
|
|
104
131
|
echo "next=$((attempts + 1))"
|
|
105
132
|
echo "minutes=$minutes"
|
|
106
133
|
echo "cause=$cause"
|
|
134
|
+
echo "failed-step=$failed_step"
|
|
135
|
+
echo "reason=$reason"
|
|
107
136
|
} >> "$GITHUB_OUTPUT"
|
|
108
|
-
echo "agent job ran for ${minutes}m; attempts so far ${attempts}; retry=${retry}; cause=${cause}"
|
|
137
|
+
echo "agent job ran for ${minutes}m; attempts so far ${attempts}; failed step '${failed_step}'; retry=${retry}; cause=${cause}"
|
|
@@ -20,19 +20,64 @@
|
|
|
20
20
|
# shutdown is read from the annotations, which only GitHub writes, and the cut-off from the one line
|
|
21
21
|
# immediately above the sandbox teardown.
|
|
22
22
|
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
23
|
+
# One cause is the opposite, and repeating it is the wrong answer:
|
|
24
|
+
#
|
|
25
|
+
# configuration the job stopped in a preflight step before the model was ever started: the
|
|
26
|
+
# harness sync and doctor (`sync-agents.mjs --strict`, `harness-doctor.mjs
|
|
27
|
+
# --strict`) or the CI provider merge. Those read this repository's own files,
|
|
28
|
+
# so the next attempt fails the same way in the same minute. A consumer with no
|
|
29
|
+
# specialist `*-engineer.md` saw five of them, each commented as a provider
|
|
30
|
+
# outage (#527). It is read from the name of the step GitHub marked failed,
|
|
31
|
+
# which the jobs API reports and no tool output can forge.
|
|
32
|
+
#
|
|
33
|
+
# Usage: classify-failure.sh LOG_FILE ANNOTATIONS_FILE [FAILED_STEPS_FILE]
|
|
34
|
+
# classify-failure.sh --fix STEP_NAME
|
|
35
|
+
# The first form prints one word: runner-shutdown, configuration, cut-off or none. FAILED_STEPS_FILE
|
|
36
|
+
# holds the names of the job's failed steps, one per line. The second form prints what a person
|
|
37
|
+
# does about a configuration failure in that step.
|
|
38
|
+
|
|
39
|
+
# The fix sentences quote commands in backticks for Markdown, not for the shell.
|
|
40
|
+
# shellcheck disable=SC2016
|
|
25
41
|
|
|
26
42
|
set -euo pipefail
|
|
27
43
|
|
|
44
|
+
# The steps in loops/workflows/shared/opencode-ci.md that only fail on the repository's own setup.
|
|
45
|
+
# Kept here, next to the sentence that tells a person how to fix each, so the two cannot drift.
|
|
46
|
+
preflight_fix() {
|
|
47
|
+
case "$1" in
|
|
48
|
+
"Sync harness agents and print model routing")
|
|
49
|
+
echo 'Run `node .opencode/scripts/sync-agents.mjs --strict` and `node .opencode/scripts/harness-doctor.mjs --strict` locally and fix what they report. `no *-engineer.md templates found` means the repository has no specialist engineer yet: run `/make-engineer` from agent-harness and commit the result.'
|
|
50
|
+
;;
|
|
51
|
+
"Merge the CI-only OpenCode provider into opencode.jsonc")
|
|
52
|
+
echo 'The step error names the file: `opencode.ci.json` must exist and be valid JSON (`workflows update` installs it), and a tracked `opencode.jsonc` must be comment-free JSON.'
|
|
53
|
+
;;
|
|
54
|
+
*) return 1 ;;
|
|
55
|
+
esac
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if [ "${1:-}" = "--fix" ]; then
|
|
59
|
+
preflight_fix "${2:-}" || echo 'Read the failed step in the run log and fix the repository setup it reports.'
|
|
60
|
+
exit 0
|
|
61
|
+
fi
|
|
62
|
+
|
|
28
63
|
log="${1:-}"
|
|
29
64
|
annotations="${2:-}"
|
|
65
|
+
failed_steps="${3:-}"
|
|
30
66
|
|
|
31
67
|
if [ -n "$annotations" ] && [ -f "$annotations" ] && grep -qiE 'exit code 143|runner has received a shutdown signal|lost communication with the server|runner.*(was|has been) (deleted|terminated)' "$annotations"; then
|
|
32
68
|
echo runner-shutdown
|
|
33
69
|
exit 0
|
|
34
70
|
fi
|
|
35
71
|
|
|
72
|
+
if [ -n "$failed_steps" ] && [ -f "$failed_steps" ]; then
|
|
73
|
+
while IFS= read -r step; do
|
|
74
|
+
if preflight_fix "$step" >/dev/null; then
|
|
75
|
+
echo configuration
|
|
76
|
+
exit 0
|
|
77
|
+
fi
|
|
78
|
+
done < "$failed_steps"
|
|
79
|
+
fi
|
|
80
|
+
|
|
36
81
|
if [ -n "$log" ] && [ -f "$log" ]; then
|
|
37
82
|
# The line the sandbox teardown starts on. grep exits 1 for none, which is an answer here.
|
|
38
83
|
stop=$(grep -n '\[INFO\] Stopping containers' "$log" | head -1 | cut -d: -f1 || true)
|
|
@@ -25,6 +25,18 @@ MERGE_GATE_WORKER_MD="${WORKFLOWS_DIR}/agent-merge-gate.md"
|
|
|
25
25
|
router_env() {
|
|
26
26
|
sed -n "s/^ $1: *//p" "$ROUTER_YML" | head -1 | sed -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'$/\1/"
|
|
27
27
|
}
|
|
28
|
+
# The App's login is the repository variable BOT_LOGIN with the package default, written as the
|
|
29
|
+
# one expression `vars.BOT_LOGIN || 'platform-devbox[bot]'` wherever it is needed: the router's
|
|
30
|
+
# TRUSTED_BOTS, bot-approve's `if:` and every worker's GH_AW_ALLOWED_BOTS. Resolve it the way
|
|
31
|
+
# GitHub does, for a repository that sets the variable (pass the login) and one that does not
|
|
32
|
+
# (pass nothing). A `${{ }}` copy resolves to the login, a bare one in an `if:` to a quoted string.
|
|
33
|
+
resolve_bot_login() {
|
|
34
|
+
local login="${1:-}" with='\1'
|
|
35
|
+
[ -z "$login" ] || with="$login"
|
|
36
|
+
sed -E \
|
|
37
|
+
-e "s/\\\$\\{\\{ vars\\.BOT_LOGIN \\|\\| '([^']*)' \\}\\}/${with}/g" \
|
|
38
|
+
-e "s/vars\\.BOT_LOGIN \\|\\| '([^']*)'/'${with}'/g"
|
|
39
|
+
}
|
|
28
40
|
AUDIT_CRON="$(router_env AUDIT_CRON)"
|
|
29
41
|
DOCS_AUDIT_CRON="$(router_env DOCS_AUDIT_CRON)"
|
|
30
42
|
export AUDIT_CRON DOCS_AUDIT_CRON
|
|
@@ -396,7 +408,7 @@ fi
|
|
|
396
408
|
# Bot logins are the same shape of problem. Most sites read env.TRUSTED_BOTS, but a job-level
|
|
397
409
|
# `if:` cannot: GitHub does not expose the env context there, so bot-approve keeps literals and
|
|
398
410
|
# they have to agree. Assert every bot login written anywhere in the router is in the list.
|
|
399
|
-
trusted="$(router_env TRUSTED_BOTS)"
|
|
411
|
+
trusted="$(router_env TRUSTED_BOTS | resolve_bot_login)"
|
|
400
412
|
if [ -z "$trusted" ]; then
|
|
401
413
|
MIRROR_OK=0; echo "FAIL: work-router.yml defines no TRUSTED_BOTS in its env: block" >&2
|
|
402
414
|
else
|
|
@@ -2808,6 +2820,188 @@ assert "the hourly sweep's assume pass reaches refine in assume mode" assume \
|
|
|
2808
2820
|
|
|
2809
2821
|
if [ "$RETRY_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
2810
2822
|
|
|
2823
|
+
echo "── Bot login ─────────────────────────────────────────────────────────────"
|
|
2824
|
+
|
|
2825
|
+
# A repository with its own GitHub App was silently untrusted (#527): the App's bot-working label
|
|
2826
|
+
# reached the router from an actor the router did not know, `authorize` said false, call-refine
|
|
2827
|
+
# skipped, and the issue sat at `refine,bot-working` with no comment and no error. The login is
|
|
2828
|
+
# the repository variable BOT_LOGIN now, defaulting to platform-devbox[bot]. Each assertion here
|
|
2829
|
+
# runs the real router and worker text with the variable resolved both ways.
|
|
2830
|
+
BOT_OK=1
|
|
2831
|
+
CUSTOM_BOT='custom-app[bot]'
|
|
2832
|
+
|
|
2833
|
+
# One expression, one default. A second spelling is a second thing to keep in step, and a bare
|
|
2834
|
+
# literal is the hard-coded login coming back.
|
|
2835
|
+
bot_files=("$ROUTER_YML")
|
|
2836
|
+
for route in "${ALL_WORKER_ROUTES[@]}"; do
|
|
2837
|
+
if worker_installed "$route"; then bot_files+=("${WORKFLOWS_DIR}/agent-${route}.md"); fi
|
|
2838
|
+
done
|
|
2839
|
+
bot_exprs=$(cat "${bot_files[@]}" | grep -v '^ *#' | { grep -oE "vars\.BOT_LOGIN[^}')]*('[^']*')?" || true; } | sort -u)
|
|
2840
|
+
if [ -n "$bot_exprs" ] && [ "$bot_exprs" != "vars.BOT_LOGIN || 'platform-devbox[bot]'" ]; then
|
|
2841
|
+
BOT_OK=0
|
|
2842
|
+
echo "FAIL: BOT_LOGIN is spelled more than one way: $(printf '%s' "$bot_exprs" | tr '\n' ';')" >&2
|
|
2843
|
+
fi
|
|
2844
|
+
stray=$(grep -n 'platform-devbox\[bot\]' "${bot_files[@]}" | grep -vE ':[0-9]+: *#' |
|
|
2845
|
+
grep -vF "vars.BOT_LOGIN || 'platform-devbox[bot]'" || true)
|
|
2846
|
+
if [ -n "$stray" ]; then
|
|
2847
|
+
BOT_OK=0
|
|
2848
|
+
echo "FAIL: platform-devbox[bot] is hard-coded outside the BOT_LOGIN expression:" >&2
|
|
2849
|
+
printf '%s\n' "$stray" >&2
|
|
2850
|
+
fi
|
|
2851
|
+
|
|
2852
|
+
# The router's authorize step, executed. `gh` answers nothing, as it does for an App, which has
|
|
2853
|
+
# no collaborator permission, so only TRUSTED_BOTS can make it trusted.
|
|
2854
|
+
authorize_script=$(awk '
|
|
2855
|
+
/- name: Check the actor can start an agent/ { found = 1 }
|
|
2856
|
+
found && /^ +run: \|$/ { body = 1; next }
|
|
2857
|
+
body && /^ {0,9}[^ ]/ { exit }
|
|
2858
|
+
body { print substr($0, 11) }' "$ROUTER_YML")
|
|
2859
|
+
fake_bin="$(mktemp -d)"
|
|
2860
|
+
printf '#!/usr/bin/env bash\nexit 1\n' > "${fake_bin}/gh"
|
|
2861
|
+
chmod +x "${fake_bin}/gh"
|
|
2862
|
+
authorize_as() {
|
|
2863
|
+
local actor="$1" login="$2" out
|
|
2864
|
+
out="$(mktemp)"
|
|
2865
|
+
if ! PATH="${fake_bin}:$PATH" GITHUB_OUTPUT="$out" GITHUB_REPOSITORY=o/r EVENT=issues ACTOR="$actor" \
|
|
2866
|
+
TRUSTED_BOTS="$(router_env TRUSTED_BOTS | resolve_bot_login "$login")" \
|
|
2867
|
+
bash -c "$authorize_script" >/dev/null 2>&1; then
|
|
2868
|
+
echo error
|
|
2869
|
+
else
|
|
2870
|
+
sed -n 's/^trusted=//p' "$out"
|
|
2871
|
+
fi
|
|
2872
|
+
rm -f "$out"
|
|
2873
|
+
}
|
|
2874
|
+
if [ -z "$authorize_script" ]; then
|
|
2875
|
+
BOT_OK=0
|
|
2876
|
+
echo "FAIL: could not extract the authorize step from work-router.yml" >&2
|
|
2877
|
+
else
|
|
2878
|
+
assert "default config still trusts platform-devbox[bot]" true "$(authorize_as 'platform-devbox[bot]' '')"
|
|
2879
|
+
assert "default config does not trust an unknown App" false "$(authorize_as "$CUSTOM_BOT" '')"
|
|
2880
|
+
assert "BOT_LOGIN makes the router trust that App" true "$(authorize_as "$CUSTOM_BOT" "$CUSTOM_BOT")"
|
|
2881
|
+
fi
|
|
2882
|
+
rm -rf "$fake_bin"
|
|
2883
|
+
# And the trusted App's hand-off reaches refine: the classifier routes it, the caller requires the
|
|
2884
|
+
# trust asserted above, and the worker's pre_activation admits the same login.
|
|
2885
|
+
assert_route "a trusted custom App's bot-working label routes refine" refine \
|
|
2886
|
+
EVENT=issues ACTION=labeled LABEL=bot-working ACTOR="$CUSTOM_BOT" \
|
|
2887
|
+
'ISSUE_LABELS=["refine","bot-working"]' EVENT_ISSUE_NUMBER=42
|
|
2888
|
+
if ! grep -qE "^ if: needs\.classify\.outputs\.route == 'refine' && needs\.authorize\.outputs\.trusted == 'true'" "$ROUTER_YML"; then
|
|
2889
|
+
BOT_OK=0
|
|
2890
|
+
echo "FAIL: call-refine no longer gates on authorize.outputs.trusted" >&2
|
|
2891
|
+
fi
|
|
2892
|
+
for route in "${ALL_WORKER_ROUTES[@]}"; do
|
|
2893
|
+
worker_md="${WORKFLOWS_DIR}/agent-${route}.md"
|
|
2894
|
+
[ -f "$worker_md" ] || continue
|
|
2895
|
+
allowed=$(sed -n 's/^ GH_AW_ALLOWED_BOTS: *//p' "$worker_md" | head -1)
|
|
2896
|
+
[ -n "$allowed" ] || continue
|
|
2897
|
+
case ",$(printf '%s' "$allowed" | resolve_bot_login | tr -d '"')," in
|
|
2898
|
+
*",platform-devbox[bot],"*) ;;
|
|
2899
|
+
*) BOT_OK=0; echo "FAIL: agent-${route} no longer admits platform-devbox[bot] by default" >&2 ;;
|
|
2900
|
+
esac
|
|
2901
|
+
case ",$(printf '%s' "$allowed" | resolve_bot_login "$CUSTOM_BOT" | tr -d '"')," in
|
|
2902
|
+
*",${CUSTOM_BOT},"*) ;;
|
|
2903
|
+
*) BOT_OK=0; echo "FAIL: agent-${route} GH_AW_ALLOWED_BOTS ignores BOT_LOGIN, so pre_activation rejects that App" >&2 ;;
|
|
2904
|
+
esac
|
|
2905
|
+
done
|
|
2906
|
+
|
|
2907
|
+
# bot-approve's `if:` cannot read env, so it reads the variable itself.
|
|
2908
|
+
approve_if=$(awk '/^ bot-approve:$/{found=1} found && /^ if: >$/{body=1; next} body && /^ [a-z-]+:/{exit} body{print}' "$ROUTER_YML")
|
|
2909
|
+
if ! printf '%s' "$approve_if" | resolve_bot_login | grep -qF "github.actor == ('platform-devbox[bot]')"; then
|
|
2910
|
+
BOT_OK=0
|
|
2911
|
+
echo "FAIL: bot-approve no longer accepts platform-devbox[bot] by default" >&2
|
|
2912
|
+
fi
|
|
2913
|
+
if ! printf '%s' "$approve_if" | resolve_bot_login "$CUSTOM_BOT" | grep -qF "github.actor == ('${CUSTOM_BOT}')"; then
|
|
2914
|
+
BOT_OK=0
|
|
2915
|
+
echo "FAIL: bot-approve does not accept the App named by BOT_LOGIN" >&2
|
|
2916
|
+
fi
|
|
2917
|
+
|
|
2918
|
+
if [ "$BOT_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
2919
|
+
|
|
2920
|
+
echo "── Preflight failures are configuration ──────────────────────────────────"
|
|
2921
|
+
|
|
2922
|
+
# A preflight step that refused the repository's setup ended a consumer's refine in a minute; the
|
|
2923
|
+
# retry rule read a short run, commented "That is what a provider outage looks like" and spent all
|
|
2924
|
+
# five attempts (#527). It is classified from the failed step's name, never retried, and reported
|
|
2925
|
+
# with the step and the fix.
|
|
2926
|
+
PREFLIGHT_OK=1
|
|
2927
|
+
RETRY_DIR="${HERE}/../decide-agent-retry"
|
|
2928
|
+
classify_steps() {
|
|
2929
|
+
local steps annotations
|
|
2930
|
+
steps="$(mktemp)"; annotations="$(mktemp)"
|
|
2931
|
+
printf '%s\n' "$1" > "$steps"
|
|
2932
|
+
printf '%s\n' "${2:-}" > "$annotations"
|
|
2933
|
+
bash "${RETRY_DIR}/classify-failure.sh" "" "$annotations" "$steps"
|
|
2934
|
+
rm -f "$steps" "$annotations"
|
|
2935
|
+
}
|
|
2936
|
+
assert "a failed harness sync is a configuration failure" configuration \
|
|
2937
|
+
"$(classify_steps 'Sync harness agents and print model routing')"
|
|
2938
|
+
assert "a failed CI provider merge is a configuration failure" configuration \
|
|
2939
|
+
"$(classify_steps 'Merge the CI-only OpenCode provider into opencode.jsonc')"
|
|
2940
|
+
assert "a failed agent step is not a configuration failure" none \
|
|
2941
|
+
"$(classify_steps 'Execute OpenCode CLI')"
|
|
2942
|
+
assert "a runner shutdown still wins over the step it interrupted" runner-shutdown \
|
|
2943
|
+
"$(classify_steps 'Sync harness agents and print model routing' 'Process completed with exit code 143.')"
|
|
2944
|
+
if ! bash "${RETRY_DIR}/classify-failure.sh" --fix 'Sync harness agents and print model routing' | grep -qF '/make-engineer'; then
|
|
2945
|
+
PREFLIGHT_OK=0
|
|
2946
|
+
echo "FAIL: the harness sync fix no longer names /make-engineer" >&2
|
|
2947
|
+
fi
|
|
2948
|
+
# The names are matched exactly, so a renamed step in the shared setup disables the rule silently.
|
|
2949
|
+
while IFS= read -r step; do
|
|
2950
|
+
[ -n "$step" ] || continue
|
|
2951
|
+
if ! grep -qF -- "- name: ${step}" "${WORKFLOWS_DIR}/shared/opencode-ci.md"; then
|
|
2952
|
+
PREFLIGHT_OK=0
|
|
2953
|
+
echo "FAIL: classify-failure.sh names preflight step '${step}', which shared/opencode-ci.md no longer has" >&2
|
|
2954
|
+
fi
|
|
2955
|
+
done < <(sed -n 's/^ "\(.*\)")$/\1/p' "${RETRY_DIR}/classify-failure.sh")
|
|
2956
|
+
|
|
2957
|
+
# The decision itself, executed with a fake `gh`: a one-minute run that died in the harness sync
|
|
2958
|
+
# is not retried even with every attempt left, and the same minute dying in the agent step is.
|
|
2959
|
+
decide_script=$(awk '/^ run: \|$/{body=1; next} body{print substr($0, 9)}' "${RETRY_DIR}/action.yml")
|
|
2960
|
+
decide_with_failed_step() {
|
|
2961
|
+
local step="$1" bin out
|
|
2962
|
+
bin="$(mktemp -d)"; out="$(mktemp)"
|
|
2963
|
+
{
|
|
2964
|
+
echo '#!/usr/bin/env bash'
|
|
2965
|
+
echo 'case "$*" in'
|
|
2966
|
+
echo ' *"/runs/"*"/jobs"*) echo "7 2026-01-01T00:00:00Z 2026-01-01T00:01:00Z failure" ;;'
|
|
2967
|
+
echo " *\"/actions/jobs/7 \"*) echo \"${step}\" ;;"
|
|
2968
|
+
echo ' *) exit 0 ;;'
|
|
2969
|
+
echo 'esac'
|
|
2970
|
+
} > "${bin}/gh"
|
|
2971
|
+
chmod +x "${bin}/gh"
|
|
2972
|
+
PATH="${bin}:$PATH" GITHUB_OUTPUT="$out" REPO=o/r RUN_ID=1 ACTION_PATH="$RETRY_DIR" \
|
|
2973
|
+
ATTEMPTS=0 PARK_AT=5 UNDER_MINUTES=10 bash -c "$decide_script" >/dev/null 2>&1 || echo "error=true" >> "$out"
|
|
2974
|
+
tr '\n' ' ' < "$out"
|
|
2975
|
+
rm -rf "$bin" "$out"
|
|
2976
|
+
}
|
|
2977
|
+
config_decision=$(decide_with_failed_step 'Sync harness agents and print model routing')
|
|
2978
|
+
case "$config_decision" in
|
|
2979
|
+
*"retry=false "*"cause=configuration "*"failed-step=Sync harness agents and print model routing "*"reason=The agent job stopped in its preflight step"*) PASS=$((PASS + 1)) ;;
|
|
2980
|
+
*) FAIL=$((FAIL + 1)); echo "FAIL: a failed harness sync is not reported as configuration without a retry: ${config_decision}" >&2 ;;
|
|
2981
|
+
esac
|
|
2982
|
+
outage_decision=$(decide_with_failed_step 'Execute OpenCode CLI')
|
|
2983
|
+
case "$outage_decision" in
|
|
2984
|
+
*"retry=true "*"cause=short-run "*) PASS=$((PASS + 1)) ;;
|
|
2985
|
+
*) FAIL=$((FAIL + 1)); echo "FAIL: a short run that died in the agent step is no longer retried: ${outage_decision}" >&2 ;;
|
|
2986
|
+
esac
|
|
2987
|
+
|
|
2988
|
+
# Every worker that uses the rule reports the configuration failure, keeps the outage wording off
|
|
2989
|
+
# it, and parks it without `stalled`, which the janitor would retry into the same failure.
|
|
2990
|
+
for route in refine implement triage apply-review; do
|
|
2991
|
+
worker_md="${WORKFLOWS_DIR}/agent-${route}.md"
|
|
2992
|
+
[ -f "$worker_md" ] || continue
|
|
2993
|
+
if ! grep -qF "steps.decide.outputs.cause == 'configuration'" "$worker_md" ||
|
|
2994
|
+
! grep -qF '${{ steps.decide.outputs.reason }}' "$worker_md"; then
|
|
2995
|
+
PREFLIGHT_OK=0
|
|
2996
|
+
echo "FAIL: agent-${route} does not report a configuration failure with its step and fix" >&2
|
|
2997
|
+
fi
|
|
2998
|
+
if ! grep -qF "steps.decide.outputs.cause != 'configuration' && env.STALLED_LABEL" "$worker_md"; then
|
|
2999
|
+
PREFLIGHT_OK=0
|
|
3000
|
+
echo "FAIL: agent-${route} flags a configuration failure as stalled, so the janitor retries it" >&2
|
|
3001
|
+
fi
|
|
3002
|
+
done
|
|
3003
|
+
if [ "$PREFLIGHT_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
3004
|
+
|
|
2811
3005
|
echo "── Ways a run ends with nothing ──────────────────────────────────────────"
|
|
2812
3006
|
|
|
2813
3007
|
# A run can end without a pull request in five ways and they need different answers. One sentence
|
|
@@ -3162,7 +3356,7 @@ for job in authorize classify; do
|
|
|
3162
3356
|
sed -n "/^ ${job}:\$/,/^ runs-on:/p" "${WORKFLOWS_DIR}/work-router.yml" | grep -qF "if: vars.AGENTIC_WORKFLOWS_DISABLED == ''" ||
|
|
3163
3357
|
{ OFF_OK=0; echo "FAIL: the router's ${job} job ignores AGENTIC_WORKFLOWS_DISABLED" >&2; }
|
|
3164
3358
|
done
|
|
3165
|
-
for f in "${WORKFLOWS_DIR}/authorize-bot-work.yml" "${WORKFLOWS_DIR}/release-tag.yml"; do
|
|
3359
|
+
for f in "${WORKFLOWS_DIR}/authorize-bot-work.yml" "${WORKFLOWS_DIR}/release-tag.yml" "${WORKFLOWS_DIR}/release-to-production.yml"; do
|
|
3166
3360
|
[ -f "$f" ] || continue
|
|
3167
3361
|
grep -qF "vars.AGENTIC_WORKFLOWS_DISABLED == ''" "$f" ||
|
|
3168
3362
|
{ OFF_OK=0; echo "FAIL: $(basename "$f") ignores AGENTIC_WORKFLOWS_DISABLED" >&2; }
|
|
@@ -3476,6 +3670,30 @@ fi
|
|
|
3476
3670
|
if [ "$GATE_RUN_ID_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
3477
3671
|
fi
|
|
3478
3672
|
|
|
3673
|
+
echo "── Release to production ──────────────────────────────────────────────────"
|
|
3674
|
+
|
|
3675
|
+
# One button ships a release: release-to-production.yml runs the release worker with deploy=true
|
|
3676
|
+
# (the router's workflow_dispatch is at GitHub's ten-input limit), the release pull request carries
|
|
3677
|
+
# a marker, and release-tag.yml deploys the tag only when it finds it. A release without the marker
|
|
3678
|
+
# must keep not deploying.
|
|
3679
|
+
RTP_YML="${WORKFLOWS_DIR}/release-to-production.yml"
|
|
3680
|
+
RT_YML="${WORKFLOWS_DIR}/release-tag.yml"
|
|
3681
|
+
if [ -f "$RTP_YML" ] && worker_installed release; then
|
|
3682
|
+
RTP_OK=1
|
|
3683
|
+
rtp() { grep -qE "$1" "$2" || { RTP_OK=0; echo "FAIL: release to production: $3" >&2; }; }
|
|
3684
|
+
rtp 'uses: \./\.github/workflows/agent-release\.lock\.yml' "$RTP_YML" 'the button must run the release worker'
|
|
3685
|
+
rtp '^ deploy: "true"$' "$RTP_YML" 'the button must ask the release worker for a deploy'
|
|
3686
|
+
rtp 'default: minor' "$RTP_YML" 'the button must default to a minor bump'
|
|
3687
|
+
rtp '^ permissions: write-all$' "$RTP_YML" 'the button must grant the worker write-all, or the run never starts'
|
|
3688
|
+
if grep -qE '^ deploy:' "$ROUTER_YML"; then RTP_OK=0; echo "FAIL: release to production: the router's workflow_dispatch is at the ten-input limit; deploy belongs to the button" >&2; fi
|
|
3689
|
+
rtp '<!-- release-deploy -->' "${WORKFLOWS_DIR}/agent-release.md" 'the release worker never writes the deploy marker'
|
|
3690
|
+
rtp 'if \[ "\$DEPLOY" = "true" \]' "${WORKFLOWS_DIR}/agent-release.md" 'the deploy marker must depend on the deploy input'
|
|
3691
|
+
rtp "if: steps.release.outputs.deploy == 'true'" "$RT_YML" 'release-tag.yml must deploy only when the release asked for it'
|
|
3692
|
+
rtp 'gh workflow run "\$DEPLOY_WORKFLOW" --repo "\$REPO" --ref "\$TAG"' "$RT_YML" 'release-tag.yml must start the deploy on the tag'
|
|
3693
|
+
rtp '^ DEPLOY_WORKFLOW: ' "$RT_YML" 'release-tag.yml does not declare DEPLOY_WORKFLOW'
|
|
3694
|
+
if [ "$RTP_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
3695
|
+
fi
|
|
3696
|
+
|
|
3479
3697
|
echo "── App error privacy ─────────────────────────────────────────────────────"
|
|
3480
3698
|
|
|
3481
3699
|
# The application error report files into the private repository rather than out of it, so
|
package/loops/docs/diagrams.md
CHANGED
|
@@ -336,10 +336,12 @@ flowchart TD
|
|
|
336
336
|
implRetry{"Died under 6 minutes<br/>with no answer?"}
|
|
337
337
|
implRetry -->|"yes: provider outage"| implAgain(("Retry<br/>attempt N of 5, label kept"))
|
|
338
338
|
implRetry -.->|"no: it answered, wrongly"| implFail
|
|
339
|
+
implRetry -.->|"no: a preflight step refused<br/>the repository's setup"| implConfig
|
|
339
340
|
implAgain -->|"re-enters via the router"| implStart
|
|
340
341
|
implHandoff(("Handed off<br/>PR open, bot-working removed,<br/>the gate decides next"))
|
|
341
342
|
implIdle(("Idle<br/>closed, or held by future"))
|
|
342
343
|
implFail(("Parked<br/>review added, a rerun<br/>would fail the same way"))
|
|
344
|
+
implConfig(("Parked as configuration<br/>review, no stalled, one comment<br/>naming the step and the fix"))
|
|
343
345
|
|
|
344
346
|
implStackStart("check-stack-branch<br/>(router job, rung 1)<br/>the issue carries<br/>stack-root-branch?") -.->|"no: flat mode,<br/>nothing happens"| implIdle
|
|
345
347
|
implStackStart -->|"yes: the workflow dispatches<br/>this worker with ref: feat/N<br/>so the agent never fetches"| implStart
|
|
@@ -354,14 +356,16 @@ flowchart TD
|
|
|
354
356
|
class implReserve,implFacts,implTodos,implCode,implPr,implStackStart action
|
|
355
357
|
class implEligible,implCheck,implVerify,implRetry decision
|
|
356
358
|
class implIdle idle
|
|
357
|
-
class implFail failure
|
|
359
|
+
class implFail,implConfig failure
|
|
358
360
|
class implHandoff,implAgain success
|
|
359
361
|
```
|
|
360
362
|
|
|
361
363
|
The retry belt exists because a provider outage kills a run in a couple of minutes with no answer,
|
|
362
364
|
and that used to burn the issue and hand it to a human. A run that worked for half an hour and then
|
|
363
365
|
failed produced an answer that was wrong; repeating it costs the whole fleet the same half hour to
|
|
364
|
-
be wrong again, so only the short deaths are retried.
|
|
366
|
+
be wrong again, so only the short deaths are retried. A short death in a preflight step (the harness
|
|
367
|
+
sync and doctor, the CI provider merge) is the exception: it is the repository's setup, not the
|
|
368
|
+
provider, so it is reported as a configuration failure with the step and the fix and never retried.
|
|
365
369
|
|
|
366
370
|
A stacked child enters the same worker through a different door: the router's `check-stack-branch`
|
|
367
371
|
job reads the issue's markers before anything runs, and dispatches the compiled workflow with
|
|
@@ -595,6 +599,7 @@ nothing reaches `main` except through a pull request.
|
|
|
595
599
|
```mermaid
|
|
596
600
|
flowchart TD
|
|
597
601
|
relStart("Work Router<br/>release route<br/>operation=release") --> relFacts
|
|
602
|
+
relButton("Release to production button<br/>bump (default minor), deploy=true") --> relFacts
|
|
598
603
|
relFacts("Facts (rung 3)<br/>Commit log since the last tag<br/>and the current version, to disk") --> relAgent
|
|
599
604
|
relAgent("Agent (rung 5)<br/>Categorise by conventional-commit<br/>prefix, write release-notes.md.<br/>The only judgement in this route") --> relNoop
|
|
600
605
|
relNoop("noop<br/>the agent writes a file,<br/>not a GitHub object") --> relConclude
|
|
@@ -603,7 +608,11 @@ flowchart TD
|
|
|
603
608
|
relMerge{"CI green and<br/>auto-merge allowed?"}
|
|
604
609
|
relMerge -->|yes| relTag
|
|
605
610
|
relMerge -.->|auto-merge disabled| relOpen
|
|
606
|
-
relTag("release-tag.yml on push to main<br/>GITHUB_TOKEN: tag vX.Y.Z,<br/>Release with the PR body as notes") -->
|
|
611
|
+
relTag("release-tag.yml on push to main<br/>GITHUB_TOKEN: tag vX.Y.Z,<br/>Release with the PR body as notes") --> relDeployQ
|
|
612
|
+
relDeployQ{"Deploy marker<br/>on the release PR?"}
|
|
613
|
+
relDeployQ -->|no| relDone
|
|
614
|
+
relDeployQ -->|yes| relDeploy
|
|
615
|
+
relDeploy(("Deploying<br/>DEPLOY_WORKFLOW started on the tag,<br/>pro reviewers still apply"))
|
|
607
616
|
relConclude -.->|fails| relFail
|
|
608
617
|
relOpen(("PR left open<br/>warning names the fix"))
|
|
609
618
|
relDone(("Released<br/>tag and Release published,<br/>no tag-triggered workflow fired"))
|
|
@@ -616,16 +625,20 @@ flowchart TD
|
|
|
616
625
|
classDef failure fill:#fff0f0,stroke:#ef2929,stroke-width:2px,color:#8b1a1a
|
|
617
626
|
classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
|
|
618
627
|
class relStart start
|
|
619
|
-
class relFacts,relAgent,relNoop,relPr,relTag action
|
|
620
|
-
class relConclude,relMerge decision
|
|
628
|
+
class relButton,relFacts,relAgent,relNoop,relPr,relTag action
|
|
629
|
+
class relConclude,relMerge,relDeployQ decision
|
|
621
630
|
class relFail failure
|
|
622
631
|
class relOpen idle
|
|
623
|
-
class relDone success
|
|
632
|
+
class relDone,relDeploy success
|
|
624
633
|
```
|
|
625
634
|
|
|
626
635
|
The version bump, the tag and the Release are deterministic on purpose. The agent's only output is
|
|
627
636
|
a file on disk, so a confused model cannot publish a release. The tag and the Release use
|
|
628
637
|
`GITHUB_TOKEN` so that a consumer deploying on `v*` tags does not deploy because a release was cut.
|
|
638
|
+
The **Release to production** button (`release-to-production.yml`) is the one way a release deploys:
|
|
639
|
+
it runs the worker directly with `deploy=true` (the router's dispatch is at GitHub's ten-input
|
|
640
|
+
limit), the release pull request carries `<!-- release-deploy -->`,
|
|
641
|
+
and only then does `release-tag.yml` start `DEPLOY_WORKFLOW` on the tag, explicitly.
|
|
629
642
|
|
|
630
643
|
|
|
631
644
|
---
|
|
@@ -37,7 +37,9 @@ env:
|
|
|
37
37
|
RETRY_COMMENT: "That is what a provider outage looks like -- the run ended before it could produce an answer -- so this is being tried again from the start. It is a fresh run rather than a continuation: nothing is carried over from the attempt that failed."
|
|
38
38
|
INCOMPLETE_COMMENT: "Applying the review feedback ended without an outcome. This worker has no retry of its own: it runs again when somebody reviews or comments on the pull request, and the issue is flagged so it is not lost until then."
|
|
39
39
|
ISSUE_CONTEXT_PATH: /tmp/gh-aw/agent/issue-context.json
|
|
40
|
-
|
|
40
|
+
# The bot actors pre_activation admits. BOT_LOGIN is the repository variable naming the App's
|
|
41
|
+
# `<slug>[bot]` login; unset, it is platform-devbox[bot]. The router trusts the same login.
|
|
42
|
+
GH_AW_ALLOWED_BOTS: "${{ vars.BOT_LOGIN || 'platform-devbox[bot]' }},github-actions[bot]"
|
|
41
43
|
GIT_AUTHOR_NAME: "github-actions[bot]"
|
|
42
44
|
GIT_AUTHOR_EMAIL: "github-actions[bot]@users.noreply.github.com"
|
|
43
45
|
GIT_COMMITTER_NAME: "github-actions[bot]"
|
|
@@ -383,6 +385,9 @@ jobs:
|
|
|
383
385
|
token: ${{ steps.app-token.outputs.token }}
|
|
384
386
|
issue-number: ${{ needs.subject.outputs.issue }}
|
|
385
387
|
labels: ${{ env.WORKING_LABEL }}
|
|
388
|
+
# A configuration failure is parked for a person without `stalled`: the janitor retries
|
|
389
|
+
# stalled work, and a preflight step that refused the repository's setup fails the same way
|
|
390
|
+
# on every retry until somebody changes the repository (#527).
|
|
386
391
|
- name: Flag for human review
|
|
387
392
|
if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
|
|
388
393
|
uses: ./.github/actions/add-issue-labels
|
|
@@ -391,9 +396,19 @@ jobs:
|
|
|
391
396
|
issue-number: ${{ needs.subject.outputs.issue }}
|
|
392
397
|
labels: |-
|
|
393
398
|
${{ env.REVIEW_LABEL }}
|
|
394
|
-
${{ env.STALLED_LABEL }}
|
|
399
|
+
${{ steps.decide.outputs.cause != 'configuration' && env.STALLED_LABEL || '' }}
|
|
400
|
+
- name: Report the configuration failure
|
|
401
|
+
if: steps.decide.outputs.cause == 'configuration'
|
|
402
|
+
uses: ./.github/actions/create-issue-comment
|
|
403
|
+
with:
|
|
404
|
+
token: ${{ steps.app-token.outputs.token }}
|
|
405
|
+
issue-number: ${{ needs.subject.outputs.issue }}
|
|
406
|
+
body: |
|
|
407
|
+
${{ env.REVIEW_MARKER }}
|
|
408
|
+
${{ steps.decide.outputs.reason }}
|
|
409
|
+
[View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
|
395
410
|
- name: Report missing review feedback outcome
|
|
396
|
-
if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
|
|
411
|
+
if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled' && steps.decide.outputs.cause != 'configuration'
|
|
397
412
|
uses: ./.github/actions/create-issue-comment
|
|
398
413
|
with:
|
|
399
414
|
token: ${{ steps.app-token.outputs.token }}
|
|
@@ -83,7 +83,9 @@ env:
|
|
|
83
83
|
# copied here before the agent starts. Context only: the prompt says how it may be used.
|
|
84
84
|
MOCKUP_BRANCH: mockups
|
|
85
85
|
MOCKUP_DIR: /tmp/gh-aw/agent/mockup
|
|
86
|
-
|
|
86
|
+
# The bot actors pre_activation admits. BOT_LOGIN is the repository variable naming the App's
|
|
87
|
+
# `<slug>[bot]` login; unset, it is platform-devbox[bot]. The router trusts the same login.
|
|
88
|
+
GH_AW_ALLOWED_BOTS: "${{ vars.BOT_LOGIN || 'platform-devbox[bot]' }},github-actions[bot]"
|
|
87
89
|
description: |
|
|
88
90
|
Implements an issue and opens a pull request. Stops there: the merge decision belongs to
|
|
89
91
|
`agent-merge-gate.md`, which runs once CI has reported. Replaces the `impl-*` chain in
|
|
@@ -617,6 +619,9 @@ jobs:
|
|
|
617
619
|
labels: |
|
|
618
620
|
${{ env.WORKING_LABEL }}
|
|
619
621
|
implement
|
|
622
|
+
# A configuration failure is parked for a person without `stalled`: the janitor retries
|
|
623
|
+
# stalled work, and a preflight step that refused the repository's setup fails the same way
|
|
624
|
+
# on every retry until somebody changes the repository (#527).
|
|
620
625
|
- name: Flag for human review
|
|
621
626
|
if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
|
|
622
627
|
uses: ./.github/actions/add-issue-labels
|
|
@@ -625,9 +630,19 @@ jobs:
|
|
|
625
630
|
issue-number: ${{ inputs.issue-number }}
|
|
626
631
|
labels: |-
|
|
627
632
|
${{ env.REVIEW_LABEL }}
|
|
628
|
-
${{ env.STALLED_LABEL }}
|
|
633
|
+
${{ steps.decide.outputs.cause != 'configuration' && env.STALLED_LABEL || '' }}
|
|
634
|
+
- name: Report the configuration failure
|
|
635
|
+
if: steps.decide.outputs.cause == 'configuration'
|
|
636
|
+
uses: ./.github/actions/create-issue-comment
|
|
637
|
+
with:
|
|
638
|
+
token: ${{ steps.app-token.outputs.token }}
|
|
639
|
+
issue-number: ${{ inputs.issue-number }}
|
|
640
|
+
body: |
|
|
641
|
+
${{ env.IMPLEMENT_MARKER }}
|
|
642
|
+
${{ steps.decide.outputs.reason }}
|
|
643
|
+
[View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
|
629
644
|
- name: Report missing implementation outcome
|
|
630
|
-
if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
|
|
645
|
+
if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled' && steps.decide.outputs.cause != 'configuration'
|
|
631
646
|
uses: ./.github/actions/create-issue-comment
|
|
632
647
|
with:
|
|
633
648
|
token: ${{ steps.app-token.outputs.token }}
|
|
@@ -105,10 +105,12 @@ env:
|
|
|
105
105
|
# one unusable report can keep every other bot pull request in the repository waiting.
|
|
106
106
|
PARK_AT_UNUSABLE_OUTPUT: "2"
|
|
107
107
|
ISSUE_CONTEXT_PATH: /tmp/gh-aw/agent/issue-context.json
|
|
108
|
-
|
|
108
|
+
# The bot actors pre_activation admits. BOT_LOGIN is the repository variable naming the App's
|
|
109
|
+
# `<slug>[bot]` login; unset, it is platform-devbox[bot]. The router trusts the same login.
|
|
110
|
+
GH_AW_ALLOWED_BOTS: "${{ vars.BOT_LOGIN || 'platform-devbox[bot]' }},github-actions[bot]"
|
|
109
111
|
# The bots whose pull requests the gate takes, in every spelling GitHub uses for one account.
|
|
110
112
|
# Any other author needs write access to the repository, as they would to push the branch.
|
|
111
|
-
TRUSTED_BOTS: "github-actions[bot] app/github-actions platform-devbox[bot]"
|
|
113
|
+
TRUSTED_BOTS: "github-actions[bot] app/github-actions ${{ vars.BOT_LOGIN || 'platform-devbox[bot]' }}"
|
|
112
114
|
GIT_AUTHOR_NAME: "github-actions[bot]"
|
|
113
115
|
GIT_AUTHOR_EMAIL: "github-actions[bot]@users.noreply.github.com"
|
|
114
116
|
GIT_COMMITTER_NAME: "github-actions[bot]"
|
|
@@ -92,7 +92,9 @@ env:
|
|
|
92
92
|
INCOMPLETE_COMMENT: "Automated refinement ended without an outcome. The refine label remains for a retry."
|
|
93
93
|
SAFE_OUTPUT_COMMENT_PREFIX: "Refinement update"
|
|
94
94
|
ISSUE_CONTEXT_PATH: /tmp/gh-aw/agent/issue-context.json
|
|
95
|
-
|
|
95
|
+
# The bot actors pre_activation admits. BOT_LOGIN is the repository variable naming the App's
|
|
96
|
+
# `<slug>[bot]` login; unset, it is platform-devbox[bot]. The router trusts the same login.
|
|
97
|
+
GH_AW_ALLOWED_BOTS: "${{ vars.BOT_LOGIN || 'platform-devbox[bot]' }},github-actions[bot]"
|
|
96
98
|
REFINE_ISSUE_PATH: /tmp/gh-aw/refine-issue.json
|
|
97
99
|
REFINE_COMMENTS_PATH: /tmp/gh-aw/refine-comments.json
|
|
98
100
|
GIT_AUTHOR_NAME: "github-actions[bot]"
|
|
@@ -939,6 +941,9 @@ jobs:
|
|
|
939
941
|
token: ${{ steps.app-token.outputs.token }}
|
|
940
942
|
issue-number: ${{ inputs.issue-number }}
|
|
941
943
|
labels: ${{ env.WORKING_LABEL }}
|
|
944
|
+
# A configuration failure is parked for a person without `stalled`: the janitor retries
|
|
945
|
+
# stalled work, and a preflight step that refused the repository's setup fails the same way
|
|
946
|
+
# on every retry until somebody changes the repository (#527).
|
|
942
947
|
- name: Flag for human review
|
|
943
948
|
if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
|
|
944
949
|
uses: ./.github/actions/add-issue-labels
|
|
@@ -947,9 +952,19 @@ jobs:
|
|
|
947
952
|
issue-number: ${{ inputs.issue-number }}
|
|
948
953
|
labels: |-
|
|
949
954
|
${{ env.REVIEW_LABEL }}
|
|
950
|
-
${{ env.STALLED_LABEL }}
|
|
955
|
+
${{ steps.decide.outputs.cause != 'configuration' && env.STALLED_LABEL || '' }}
|
|
956
|
+
- name: Report the configuration failure
|
|
957
|
+
if: steps.decide.outputs.cause == 'configuration'
|
|
958
|
+
uses: ./.github/actions/create-issue-comment
|
|
959
|
+
with:
|
|
960
|
+
token: ${{ steps.app-token.outputs.token }}
|
|
961
|
+
issue-number: ${{ inputs.issue-number }}
|
|
962
|
+
body: |
|
|
963
|
+
${{ env.REFINE_MARKER }}
|
|
964
|
+
${{ steps.decide.outputs.reason }}
|
|
965
|
+
[View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
|
951
966
|
- name: Report missing refinement outcome
|
|
952
|
-
if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
|
|
967
|
+
if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled' && steps.decide.outputs.cause != 'configuration'
|
|
953
968
|
uses: ./.github/actions/create-issue-comment
|
|
954
969
|
with:
|
|
955
970
|
token: ${{ steps.app-token.outputs.token }}
|
|
@@ -40,6 +40,11 @@ on:
|
|
|
40
40
|
required: false
|
|
41
41
|
type: string
|
|
42
42
|
default: "auto"
|
|
43
|
+
deploy:
|
|
44
|
+
description: "true when release-to-production.yml asked for a deploy: the release PR then carries the deploy marker"
|
|
45
|
+
required: false
|
|
46
|
+
type: string
|
|
47
|
+
default: "false"
|
|
43
48
|
|
|
44
49
|
runs-on: agents-arc
|
|
45
50
|
runs-on-slim: agents-arc
|
|
@@ -233,10 +238,18 @@ jobs:
|
|
|
233
238
|
BASE: ${{ steps.branch.outputs.base }}
|
|
234
239
|
BRANCH: ${{ steps.branch.outputs.branch }}
|
|
235
240
|
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
241
|
+
DEPLOY: ${{ inputs.deploy }}
|
|
236
242
|
run: |
|
|
237
243
|
set -euo pipefail
|
|
244
|
+
# The deploy marker is what makes release-tag.yml start the production deploy on the
|
|
245
|
+
# new tag. Only release-to-production.yml asks for it; a plain release route never does.
|
|
238
246
|
{
|
|
239
247
|
echo "<!-- release:v${NEW_VERSION} -->"
|
|
248
|
+
if [ "$DEPLOY" = "true" ]; then
|
|
249
|
+
echo "<!-- release-deploy -->"
|
|
250
|
+
echo "> Deploys to production once this merges and v${NEW_VERSION} is tagged."
|
|
251
|
+
echo
|
|
252
|
+
fi
|
|
240
253
|
cat /tmp/release-notes.md
|
|
241
254
|
} > /tmp/release-pr-body.md
|
|
242
255
|
url=$(gh pr create \
|
|
@@ -45,7 +45,9 @@ env:
|
|
|
45
45
|
SAFE_OUTPUT_COMMENT_PREFIX: "Triage assessment"
|
|
46
46
|
ISSUE_CONTEXT_PATH: /tmp/gh-aw/agent/issue-context.json
|
|
47
47
|
OPEN_ISSUES_PATH: /tmp/gh-aw/agent/open-issues.json
|
|
48
|
-
|
|
48
|
+
# The bot actors pre_activation admits. BOT_LOGIN is the repository variable naming the App's
|
|
49
|
+
# `<slug>[bot]` login; unset, it is platform-devbox[bot]. The router trusts the same login.
|
|
50
|
+
GH_AW_ALLOWED_BOTS: "${{ vars.BOT_LOGIN || 'platform-devbox[bot]' }},github-actions[bot]"
|
|
49
51
|
GIT_AUTHOR_NAME: "github-actions[bot]"
|
|
50
52
|
GIT_AUTHOR_EMAIL: "github-actions[bot]@users.noreply.github.com"
|
|
51
53
|
GIT_COMMITTER_NAME: "github-actions[bot]"
|
|
@@ -389,6 +391,9 @@ jobs:
|
|
|
389
391
|
token: ${{ steps.app-token.outputs.token }}
|
|
390
392
|
issue-number: ${{ inputs.issue-number }}
|
|
391
393
|
labels: ${{ env.WORKING_LABEL }}
|
|
394
|
+
# A configuration failure is parked for a person without `stalled`: the janitor retries
|
|
395
|
+
# stalled work, and a preflight step that refused the repository's setup fails the same way
|
|
396
|
+
# on every retry until somebody changes the repository (#527).
|
|
392
397
|
- name: Flag for human review
|
|
393
398
|
if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
|
|
394
399
|
uses: ./.github/actions/add-issue-labels
|
|
@@ -397,9 +402,19 @@ jobs:
|
|
|
397
402
|
issue-number: ${{ inputs.issue-number }}
|
|
398
403
|
labels: |-
|
|
399
404
|
${{ env.REVIEW_LABEL }}
|
|
400
|
-
${{ env.STALLED_LABEL }}
|
|
405
|
+
${{ steps.decide.outputs.cause != 'configuration' && env.STALLED_LABEL || '' }}
|
|
406
|
+
- name: Report the configuration failure
|
|
407
|
+
if: steps.decide.outputs.cause == 'configuration'
|
|
408
|
+
uses: ./.github/actions/create-issue-comment
|
|
409
|
+
with:
|
|
410
|
+
token: ${{ steps.app-token.outputs.token }}
|
|
411
|
+
issue-number: ${{ inputs.issue-number }}
|
|
412
|
+
body: |
|
|
413
|
+
${{ env.TRIAGE_MARKER }}
|
|
414
|
+
${{ steps.decide.outputs.reason }}
|
|
415
|
+
[View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
|
401
416
|
- name: Report missing triage outcome
|
|
402
|
-
if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
|
|
417
|
+
if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled' && steps.decide.outputs.cause != 'configuration'
|
|
403
418
|
uses: ./.github/actions/create-issue-comment
|
|
404
419
|
with:
|
|
405
420
|
token: ${{ steps.app-token.outputs.token }}
|
|
@@ -19,6 +19,14 @@ on:
|
|
|
19
19
|
permissions:
|
|
20
20
|
contents: write
|
|
21
21
|
pull-requests: read
|
|
22
|
+
# Starting DEPLOY_WORKFLOW on the tag when the release pull request asked for a deploy.
|
|
23
|
+
actions: write
|
|
24
|
+
|
|
25
|
+
env:
|
|
26
|
+
# The workflow that deploys a `v*` tag to production. Started on the new tag, with GITHUB_TOKEN,
|
|
27
|
+
# only when the release pull request carries the deploy marker (release-to-production.yml).
|
|
28
|
+
DEPLOY_WORKFLOW: app-deploy.yml
|
|
29
|
+
DEPLOY_MARKER: "<!-- release-deploy -->"
|
|
22
30
|
|
|
23
31
|
concurrency:
|
|
24
32
|
group: release-tag
|
|
@@ -38,6 +46,7 @@ jobs:
|
|
|
38
46
|
with:
|
|
39
47
|
persist-credentials: false
|
|
40
48
|
- name: Tag the release commit and publish the GitHub Release
|
|
49
|
+
id: release
|
|
41
50
|
env:
|
|
42
51
|
GH_TOKEN: ${{ github.token }}
|
|
43
52
|
REPO: ${{ github.repository }}
|
|
@@ -80,12 +89,19 @@ jobs:
|
|
|
80
89
|
marker="<!-- release:${tag} -->"
|
|
81
90
|
gh api "repos/$REPO/commits/$SHA/pulls" > /tmp/pulls.json
|
|
82
91
|
MARKER="$marker" node -e '
|
|
83
|
-
const
|
|
92
|
+
const fs = require("fs");
|
|
93
|
+
const pulls = JSON.parse(fs.readFileSync("/tmp/pulls.json", "utf8"));
|
|
84
94
|
const pr = pulls.find((p) => p.merged_at && (p.body || "").includes(process.env.MARKER));
|
|
85
95
|
if (!pr) process.exit(3);
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
96
|
+
const deploy = pr.body.includes(process.env.DEPLOY_MARKER);
|
|
97
|
+
// Neither marker nor the deploy notice belongs in the published release notes.
|
|
98
|
+
const notes = pr.body.split(/\r?\n/)
|
|
99
|
+
.filter((line) => line.trim() !== process.env.MARKER && line.trim() !== process.env.DEPLOY_MARKER)
|
|
100
|
+
.filter((line) => !line.startsWith("> Deploys to production once this merges"))
|
|
101
|
+
.join("\n").trim();
|
|
102
|
+
fs.writeFileSync("/tmp/release-notes.md", notes + "\n");
|
|
103
|
+
fs.writeFileSync("/tmp/release-deploy", deploy ? "true" : "false");
|
|
104
|
+
console.log(`Notes from #${pr.number}${deploy ? ", deploy requested" : ""}`);
|
|
89
105
|
' || status=$?
|
|
90
106
|
|
|
91
107
|
if [ "${status:-0}" -eq 3 ]; then
|
|
@@ -98,3 +114,30 @@ jobs:
|
|
|
98
114
|
gh release create "$tag" --repo "$REPO" --target "$SHA" --title "$tag" --notes-file /tmp/release-notes.md
|
|
99
115
|
fi
|
|
100
116
|
echo "Tagged $SHA as $tag and published the GitHub Release."
|
|
117
|
+
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
|
118
|
+
echo "deploy=$(cat /tmp/release-deploy 2>/dev/null || echo false)" >> "$GITHUB_OUTPUT"
|
|
119
|
+
|
|
120
|
+
# The tag above was created with GITHUB_TOKEN, so its push starts no workflow: a release does
|
|
121
|
+
# not ship by itself. A workflow_dispatch is the exception GitHub allows that token, so a
|
|
122
|
+
# release that asked for a deploy starts DEPLOY_WORKFLOW on the tag explicitly. The deploy
|
|
123
|
+
# workflow's own `pro` environment rules (required reviewers) still apply.
|
|
124
|
+
- name: Deploy the release to production
|
|
125
|
+
if: steps.release.outputs.deploy == 'true'
|
|
126
|
+
env:
|
|
127
|
+
GH_TOKEN: ${{ github.token }}
|
|
128
|
+
REPO: ${{ github.repository }}
|
|
129
|
+
TAG: ${{ steps.release.outputs.tag }}
|
|
130
|
+
shell: bash
|
|
131
|
+
run: |
|
|
132
|
+
set -euo pipefail
|
|
133
|
+
if [ ! -f ".github/workflows/${DEPLOY_WORKFLOW}" ]; then
|
|
134
|
+
echo "::warning::The release asked for a deploy, but .github/workflows/${DEPLOY_WORKFLOW} does not exist; $TAG was tagged and not deployed."
|
|
135
|
+
exit 0
|
|
136
|
+
fi
|
|
137
|
+
gh workflow run "$DEPLOY_WORKFLOW" --repo "$REPO" --ref "$TAG"
|
|
138
|
+
echo "Started $DEPLOY_WORKFLOW on $TAG."
|
|
139
|
+
{
|
|
140
|
+
echo "### $TAG is deploying to production"
|
|
141
|
+
echo
|
|
142
|
+
echo "Follow it in [${DEPLOY_WORKFLOW}](${GITHUB_SERVER_URL}/${REPO}/actions/workflows/${DEPLOY_WORKFLOW})."
|
|
143
|
+
} >> "$GITHUB_STEP_SUMMARY"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/workflows/release-to-production.yml. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
+
name: Release to production
|
|
3
|
+
# The one button for a production release, run from the repository's Actions tab (or the badge in
|
|
4
|
+
# its README): Run workflow, then Run workflow again. It runs the release worker with the bump
|
|
5
|
+
# chosen here and deploy=true: the worker opens the `chore(release): vX.Y.Z` pull request carrying a
|
|
6
|
+
# deploy marker, it auto-merges, release-tag.yml tags it, and because of the marker starts
|
|
7
|
+
# DEPLOY_WORKFLOW on the new tag, which ships to production. A `pro` environment with required
|
|
8
|
+
# reviewers still asks a person before the production stage runs.
|
|
9
|
+
#
|
|
10
|
+
# It calls the worker directly, as the router's call-release job does, because the router's
|
|
11
|
+
# workflow_dispatch is at GitHub's limit of ten inputs and has no room for a deploy flag. Running a
|
|
12
|
+
# workflow by hand already requires write access to the repository, which is what the router's
|
|
13
|
+
# authorize job checks. The release route on its own (Main Work Router, operation=release) is
|
|
14
|
+
# unchanged: it tags and publishes a release without deploying anything.
|
|
15
|
+
on:
|
|
16
|
+
workflow_dispatch:
|
|
17
|
+
inputs:
|
|
18
|
+
bump:
|
|
19
|
+
description: Version bump
|
|
20
|
+
required: true
|
|
21
|
+
type: choice
|
|
22
|
+
default: minor
|
|
23
|
+
options:
|
|
24
|
+
- minor
|
|
25
|
+
- patch
|
|
26
|
+
- major
|
|
27
|
+
|
|
28
|
+
permissions:
|
|
29
|
+
contents: read
|
|
30
|
+
|
|
31
|
+
concurrency:
|
|
32
|
+
group: release-to-production
|
|
33
|
+
cancel-in-progress: false
|
|
34
|
+
|
|
35
|
+
jobs:
|
|
36
|
+
release:
|
|
37
|
+
name: Release with a deploy
|
|
38
|
+
# AGENTIC_WORKFLOWS_DISABLED, a repository variable with any value, turns the automation off.
|
|
39
|
+
if: vars.AGENTIC_WORKFLOWS_DISABLED == ''
|
|
40
|
+
uses: ./.github/workflows/agent-release.lock.yml
|
|
41
|
+
# The caller grants what the worker requests; narrower grants make the whole run fail to start.
|
|
42
|
+
permissions: write-all
|
|
43
|
+
with:
|
|
44
|
+
version-bump: ${{ inputs.bump }}
|
|
45
|
+
deploy: "true"
|
|
46
|
+
secrets:
|
|
47
|
+
OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
|
|
48
|
+
# The release PR is pushed and opened with the App token so its CI runs; see the worker's conclude.
|
|
49
|
+
BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
|
|
50
|
+
BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
@@ -138,7 +138,12 @@ env:
|
|
|
138
138
|
# separated, and every login the same account can present: GitHub reports an App as
|
|
139
139
|
# `name[bot]` on some endpoints and `app/name` on others, so both spellings belong here.
|
|
140
140
|
# Adding a third bot used to mean finding four hand-written comparisons across this file.
|
|
141
|
-
|
|
141
|
+
# The App the repository's BOT_APP_ID belongs to is the repository variable BOT_LOGIN, its
|
|
142
|
+
# `<slug>[bot]` login; unset, it is platform-devbox[bot]. The same expression sits in
|
|
143
|
+
# bot-approve's `if:` and in every worker's GH_AW_ALLOWED_BOTS, so one variable moves all three.
|
|
144
|
+
# Without it a repository with its own App was silently untrusted: the App's bot-working label
|
|
145
|
+
# left `authorize` false and the worker never ran, with no comment and no error (#527).
|
|
146
|
+
TRUSTED_BOTS: "github-actions[bot] app/github-actions ${{ vars.BOT_LOGIN || 'platform-devbox[bot]' }}"
|
|
142
147
|
# How long an issue may sit parked with `stalled` before the janitor retries it, and how many
|
|
143
148
|
# times it tries before leaving the issue to a person. Only machine failures are retried; a
|
|
144
149
|
# verdict the agent reached on purpose is reported and never re-run, because re-running a
|
|
@@ -1215,7 +1220,7 @@ jobs:
|
|
|
1215
1220
|
needs: classify
|
|
1216
1221
|
if: >
|
|
1217
1222
|
needs.classify.outputs.route == 'bot-approve' &&
|
|
1218
|
-
(github.actor == 'app/github-actions' || github.actor == 'platform-devbox[bot]')
|
|
1223
|
+
(github.actor == 'app/github-actions' || github.actor == (vars.BOT_LOGIN || 'platform-devbox[bot]'))
|
|
1219
1224
|
runs-on: [agents-aw, "affinity-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}"]
|
|
1220
1225
|
timeout-minutes: 5
|
|
1221
1226
|
permissions:
|