@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.
@@ -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 and how many attempts have been made.
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
- # An unknown duration is treated as a long run: never retry on a guess.
76
- if [ "$minutes" -ge 0 ] && [ "$minutes" -lt "$UNDER_MINUTES" ] && [ "$attempts" -lt "$PARK_AT" ]; then
77
- retry=true
78
- cause=short-run
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
- # A long run is repeated only when it was killed, not when it was wrong. The reads are best
81
- # effort: a log or an annotation list that cannot be fetched leaves the run parked, exactly
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
- if [ "$kind" != none ]; then
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
- # Usage: classify-failure.sh LOG_FILE ANNOTATIONS_FILE
24
- # Prints one word: runner-shutdown, cut-off or none.
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
@@ -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") --> relDone
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
- GH_AW_ALLOWED_BOTS: "platform-devbox[bot],github-actions[bot]"
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
- GH_AW_ALLOWED_BOTS: "platform-devbox[bot],github-actions[bot]"
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
- GH_AW_ALLOWED_BOTS: "platform-devbox[bot],github-actions[bot]"
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
- GH_AW_ALLOWED_BOTS: "platform-devbox[bot],github-actions[bot]"
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
- GH_AW_ALLOWED_BOTS: "platform-devbox[bot],github-actions[bot]"
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 pulls = JSON.parse(require("fs").readFileSync("/tmp/pulls.json", "utf8"));
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 notes = pr.body.split(/\r?\n/).filter((line) => line.trim() !== process.env.MARKER).join("\n").trim();
87
- require("fs").writeFileSync("/tmp/release-notes.md", notes + "\n");
88
- console.log(`Notes from #${pr.number}`);
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
- TRUSTED_BOTS: "github-actions[bot] app/github-actions platform-devbox[bot]"
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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "1.18.1",
3
+ "version": "1.20.0",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",