@plainconceptsplatform/workflows 1.5.0 → 1.6.10

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.
@@ -331,7 +331,19 @@ for (const file of readdirSync(workflowDirectory)) {
331
331
  // runner rather than the run so a runner keeps its server warm between its own jobs.
332
332
  // No double quotes here: the enclosing run: is a double-quoted YAML scalar, so a quote
333
333
  // would have to be escaped. The default has no spaces, so bare is safe shell.
334
- .replace(/\/tmp\/opencode-data(?!-\$\{\{)/g, () => '/tmp/opencode-data-${{ runner.name }}');
334
+ .replace(/\/tmp\/opencode-data(?!-\$\{\{)/g, () => '/tmp/opencode-data-${{ runner.name }}')
335
+ // The engine key falls back to FORGE_API_KEY, the one secret every repository here has. The
336
+ // router maps it to OPENAI_API_KEY when it CALLS a worker, but a stack child is DISPATCHED on
337
+ // its feature branch, and a dispatched run reads repository secrets by their own names: with
338
+ // no OPENAI_API_KEY or CODEX_API_KEY set, activation died on "Validate CODEX_API_KEY or
339
+ // OPENAI_API_KEY secret" and every stack child in Pliny-Bot failed that way for hours. gh-aw
340
+ // reads the key in four places, the validation step, the engine env and the redaction list
341
+ // included, and all of them must see the fallback or the key would run unredacted. The
342
+ // longest form goes first so the shorter ones do not match inside it.
343
+ .replaceAll('${{ secrets.CODEX_API_KEY || secrets.OPENAI_API_KEY }}',
344
+ '${{ secrets.CODEX_API_KEY || secrets.OPENAI_API_KEY || secrets.FORGE_API_KEY }}')
345
+ .replaceAll('${{ secrets.CODEX_API_KEY }}', '${{ secrets.CODEX_API_KEY || secrets.FORGE_API_KEY }}')
346
+ .replaceAll('${{ secrets.OPENAI_API_KEY }}', '${{ secrets.OPENAI_API_KEY || secrets.FORGE_API_KEY }}');
335
347
 
336
348
  let rewritten = patched;
337
349
  // A queued implement executes hours after its run was created, but actions/checkout
@@ -340,8 +352,8 @@ for (const file of readdirSync(workflowDirectory)) {
340
352
  // every later push became a rebase onto a moved parent, which gh-aw's signed-commit
341
353
  // push cannot do (it dies with "cannot rebase: You have unstaged changes" and files the
342
354
  // pull request as an issue instead of a pull request). Checking out the branch tip at
343
- // execution time makes the commit parent current main; the implement-global queue
344
- // already keeps sibling merges out of the window while the agent runs.
355
+ // execution time makes the commit parent current main; the implement queue, shared by the
356
+ // children of one refine, keeps sibling merges out of the window while the agent runs.
345
357
  //
346
358
  // A stack child dispatches this workflow directly (`gh workflow run
347
359
  // agent-implement.lock.yml --ref feat/N`), and there the pin must select the feature
@@ -415,6 +427,11 @@ for (const file of readdirSync(workflowDirectory)) {
415
427
  for (const check of [
416
428
  { name: "bundle upload glob", ok: /\n( +)- name: Upload agent artifacts\n\1 if: always\(\)\n\1 continue-on-error: true\n\1 uses: actions\/upload-artifact@[^\n]*\n\1 with:\n\1 name: \$\{\{ needs\.activation\.outputs\.artifact_prefix \}\}agent\n\1 path: \|\n(?:\1 [^\n]*\n)*\1 \/tmp\/gh-aw\/aw-\*\.bundle\n/ },
417
429
  { name: "placeholder step intact", ok: /\n( +)- name: Write agent output placeholder if missing\n\1 if: always\(\)\n\1 run: \|\n\1 if \[ ! -f \/tmp\/gh-aw\/agent_output\.json \]; then\n\1 echo '\{"items":\[\]\}' > \/tmp\/gh-aw\/agent_output\.json\n\1 fi\n/ },
430
+ // Every read of the engine key carries the FORGE_API_KEY fallback; see the rewrite above.
431
+ {
432
+ name: "engine key falls back to FORGE_API_KEY",
433
+ ok: lock => !lock.includes("${{ secrets.OPENAI_API_KEY }}") && !lock.includes("${{ secrets.CODEX_API_KEY }}"),
434
+ },
418
435
  { name: "failed-jobs reporter disabled", ok: /- name: Report failed jobs\n[ \t]*id: report_failed_jobs\n[ \t]*if: false[^\n]*\n/ },
419
436
  // Only agent-refine declares update_issue; the other workers never carry the tool.
420
437
  {
@@ -280,32 +280,31 @@ jobs:
280
280
  # needs contents: write this workflow does not have, and fails the run that gates deploy.
281
281
  upload-release-assets: false
282
282
 
283
- # A bot pull request is opened with GITHUB_TOKEN, and GitHub delivers no workflow_run event
284
- # for the CI runs that follow (the run's actor is the bot). The router's CI-completion route
285
- # never sees them, so this job hands the verdict over from inside CI. workflow_dispatch is
286
- # one of the two events GITHUB_TOKEN may raise, so no App token is needed here, and the
287
- # router treats a dispatch as trusted. Human pull requests are left to the workflow_run
288
- # route, which does fire for them. The reconcile belt remains the hourly fallback.
283
+ # Hands this run's verdict to the router's merge train, which decides whether this pull request
284
+ # is the one in front and what it needs. A pull request opened or updated with a bot token gets
285
+ # no workflow_run event for its CI (the run's actor is the bot), and the train brings people's
286
+ # pull requests up to date with one too, so every pull request on this repository hands over
287
+ # from here. workflow_dispatch is one of the two events GITHUB_TOKEN may raise, so no App token
288
+ # is needed, and the router treats a dispatch as trusted. The hourly schedule is the fallback.
289
289
  #
290
290
  # If a deploy pipeline calls this workflow with `uses:`, that caller must also grant
291
291
  # `actions: write`. A called workflow may not request a scope its caller withheld, and the
292
292
  # whole run fails to start with zero jobs and no annotation when it does.
293
- dispatch-merge-gate:
294
- name: Dispatch merge gate
293
+ dispatch-merge-train:
294
+ name: Dispatch merge train
295
295
  needs: [api, mutation-api, web, mutation-web]
296
296
  if: >
297
297
  always() &&
298
298
  github.event_name == 'pull_request' &&
299
299
  github.event.pull_request.head.repo.full_name == github.repository &&
300
- github.event.pull_request.draft == false &&
301
- github.event.pull_request.user.type == 'Bot'
300
+ github.event.pull_request.draft == false
302
301
  runs-on: agents-arc
303
302
  timeout-minutes: 5
304
303
  permissions:
305
304
  contents: read
306
305
  actions: write
307
306
  steps:
308
- - name: Dispatch the merge gate with this run's verdict
307
+ - name: Hand this run's verdict to the merge train
309
308
  env:
310
309
  GH_TOKEN: ${{ github.token }}
311
310
  REPO: ${{ github.repository }}
@@ -324,10 +323,10 @@ jobs:
324
323
  done
325
324
  # A cancelled run was superseded by a newer push, whose own run dispatches.
326
325
  if [ "$conclusion" = cancelled ]; then
327
- echo "CI was cancelled; not dispatching the merge gate."
326
+ echo "CI was cancelled; not moving the merge train."
328
327
  exit 0
329
328
  fi
330
- echo "Dispatching the merge gate for PR #$PR_NUMBER with CI $conclusion (run $RUN_ID)."
329
+ echo "Moving the merge train for PR #$PR_NUMBER with CI $conclusion (run $RUN_ID)."
331
330
  gh workflow run work-router.yml --repo "$REPO" --ref "$REF" \
332
- -f operation=merge-gate -f pr-number="$PR_NUMBER" \
331
+ -f operation=merge-train -f pr-number="$PR_NUMBER" \
333
332
  -f ci-conclusion="$conclusion" -f ci-run-id="$RUN_ID"
@@ -210,32 +210,31 @@ jobs:
210
210
  # needs contents: write this workflow does not have, and fails the run that gates deploy.
211
211
  upload-release-assets: false
212
212
 
213
- # A bot pull request is opened with GITHUB_TOKEN, and GitHub delivers no workflow_run event
214
- # for the CI runs that follow (the run's actor is the bot). The router's CI-completion route
215
- # never sees them, so this job hands the verdict over from inside CI. workflow_dispatch is
216
- # one of the two events GITHUB_TOKEN may raise, so no App token is needed here, and the
217
- # router treats a dispatch as trusted. Human pull requests are left to the workflow_run
218
- # route, which does fire for them. The reconcile belt remains the hourly fallback.
213
+ # Hands this run's verdict to the router's merge train, which decides whether this pull request
214
+ # is the one in front and what it needs. A pull request opened or updated with a bot token gets
215
+ # no workflow_run event for its CI (the run's actor is the bot), and the train brings people's
216
+ # pull requests up to date with one too, so every pull request on this repository hands over
217
+ # from here. workflow_dispatch is one of the two events GITHUB_TOKEN may raise, so no App token
218
+ # is needed, and the router treats a dispatch as trusted. The hourly schedule is the fallback.
219
219
  #
220
220
  # If a deploy pipeline calls this workflow with `uses:`, that caller must also grant
221
221
  # `actions: write`. A called workflow may not request a scope its caller withheld, and the
222
222
  # whole run fails to start with zero jobs and no annotation when it does.
223
- dispatch-merge-gate:
224
- name: Dispatch merge gate
223
+ dispatch-merge-train:
224
+ name: Dispatch merge train
225
225
  needs: [lint, web, mutation-web, e2e, desktop, mobile]
226
226
  if: >
227
227
  always() &&
228
228
  github.event_name == 'pull_request' &&
229
229
  github.event.pull_request.head.repo.full_name == github.repository &&
230
- github.event.pull_request.draft == false &&
231
- github.event.pull_request.user.type == 'Bot'
230
+ github.event.pull_request.draft == false
232
231
  runs-on: agents-arc
233
232
  timeout-minutes: 5
234
233
  permissions:
235
234
  contents: read
236
235
  actions: write
237
236
  steps:
238
- - name: Dispatch the merge gate with this run's verdict
237
+ - name: Hand this run's verdict to the merge train
239
238
  env:
240
239
  GH_TOKEN: ${{ github.token }}
241
240
  REPO: ${{ github.repository }}
@@ -254,10 +253,10 @@ jobs:
254
253
  done
255
254
  # A cancelled run was superseded by a newer push, whose own run dispatches.
256
255
  if [ "$conclusion" = cancelled ]; then
257
- echo "CI was cancelled; not dispatching the merge gate."
256
+ echo "CI was cancelled; not moving the merge train."
258
257
  exit 0
259
258
  fi
260
- echo "Dispatching the merge gate for PR #$PR_NUMBER with CI $conclusion (run $RUN_ID)."
259
+ echo "Moving the merge train for PR #$PR_NUMBER with CI $conclusion (run $RUN_ID)."
261
260
  gh workflow run work-router.yml --repo "$REPO" --ref "$REF" \
262
- -f operation=merge-gate -f pr-number="$PR_NUMBER" \
261
+ -f operation=merge-train -f pr-number="$PR_NUMBER" \
263
262
  -f ci-conclusion="$conclusion" -f ci-run-id="$RUN_ID"
@@ -185,7 +185,7 @@ jobs:
185
185
  fi
186
186
 
187
187
  # A queued implement run can start long after it was dispatched. If triage concluded
188
- # needs-maintainer while this run waited in the implement-global queue, the issue now
188
+ # needs-maintainer while this run waited in its implement queue, the issue now
189
189
  # carries review and the verdict must stand: the reserve step clears review blindly,
190
190
  # so eligibility has to catch it first (Pliny-Bot #372).
191
191
  if jq -e 'index("review")' >/dev/null <<<"$labels"; then
@@ -245,6 +245,8 @@ jobs:
245
245
  contents: read
246
246
  issues: write
247
247
  pull-requests: write
248
+ # reads the agent job's log and annotations, and re-enters through the router on a retry
249
+ actions: write
248
250
  steps:
249
251
  - name: Checkout workflow actions
250
252
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -433,8 +435,48 @@ jobs:
433
435
  ${{ env.IMPLEMENT_MARKER }}
434
436
  ${{ env.PATCH_WITHOUT_REQUEST_COMMENT }}
435
437
  [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
436
- - name: Flag a run that produced nothing
438
+ # A run that ends with nothing is not always the agent's verdict. The AI gateway can answer
439
+ # nothing and end the engine one turn after it began, and the runner's VM can be taken away
440
+ # mid-job; neither says the work was wrong, and parking them for the janitor cost up to six
441
+ # hours each (Pliny-Bot #693's first attempt). The shared rule repeats those, and a run that
442
+ # died young, within the attempt budget.
443
+ - name: Decide whether an empty run is worth repeating
444
+ id: empty_retry
437
445
  if: needs.safe_outputs.outputs.created_pr_number == '' && !contains(needs.agent.outputs.output_types, 'create_pull_request') && !contains(needs.agent.outputs.output_types, 'noop') && needs.agent.outputs.has_patch != 'true'
446
+ uses: ./.github/actions/decide-agent-retry
447
+ with:
448
+ token: ${{ github.token }}
449
+ attempts-so-far: ${{ inputs.attempts_so_far || '0' }}
450
+ park-at: ${{ env.PARK_AT_ATTEMPT }}
451
+ under-minutes: ${{ env.RETRY_UNDER_MINUTES }}
452
+ - name: Report the retried empty run
453
+ if: steps.empty_retry.outputs.retry == 'true'
454
+ uses: ./.github/actions/create-issue-comment
455
+ with:
456
+ token: ${{ steps.app-token.outputs.token }}
457
+ issue-number: ${{ inputs.issue-number }}
458
+ body: |
459
+ ${{ env.ATTEMPT_MARKER }}
460
+ Attempt ${{ steps.empty_retry.outputs.next }} of ${{ env.MAX_ATTEMPTS }} ended with nothing after ${{ steps.empty_retry.outputs.minutes }} minutes (${{ steps.empty_retry.outputs.cause }}). That is what a provider or runner outage looks like, not a verdict on the work, so this is being retried.
461
+ The issue keeps `implement`.
462
+ [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
463
+ - name: Send the issue back through the router
464
+ if: steps.empty_retry.outputs.retry == 'true'
465
+ env:
466
+ GH_TOKEN: ${{ github.token }}
467
+ REPO: ${{ github.repository }}
468
+ REF: ${{ github.event.repository.default_branch }}
469
+ ISSUE_NUMBER: ${{ inputs.issue-number }}
470
+ NEXT: ${{ steps.empty_retry.outputs.next }}
471
+ run: |
472
+ set -euo pipefail
473
+ # The provider recovers in seconds, so pause before re-entering rather than dispatching
474
+ # back into the same outage.
475
+ sleep 30
476
+ gh workflow run work-router.yml --repo "$REPO" --ref "$REF" -f operation=implement -f issue-number="$ISSUE_NUMBER" -f attempts_so_far="$NEXT"
477
+ echo "Re-dispatched implement for #$ISSUE_NUMBER as attempt $NEXT."
478
+ - name: Flag a run that produced nothing
479
+ if: needs.safe_outputs.outputs.created_pr_number == '' && !contains(needs.agent.outputs.output_types, 'create_pull_request') && !contains(needs.agent.outputs.output_types, 'noop') && needs.agent.outputs.has_patch != 'true' && steps.empty_retry.outputs.retry != 'true'
438
480
  uses: ./.github/actions/add-issue-labels
439
481
  with:
440
482
  token: ${{ steps.app-token.outputs.token }}
@@ -443,7 +485,7 @@ jobs:
443
485
  ${{ env.REVIEW_LABEL }}
444
486
  ${{ env.STALLED_LABEL }}
445
487
  - name: Say the run produced nothing
446
- if: needs.safe_outputs.outputs.created_pr_number == '' && !contains(needs.agent.outputs.output_types, 'create_pull_request') && !contains(needs.agent.outputs.output_types, 'noop') && needs.agent.outputs.has_patch != 'true'
488
+ if: needs.safe_outputs.outputs.created_pr_number == '' && !contains(needs.agent.outputs.output_types, 'create_pull_request') && !contains(needs.agent.outputs.output_types, 'noop') && needs.agent.outputs.has_patch != 'true' && steps.empty_retry.outputs.retry != 'true'
447
489
  uses: ./.github/actions/create-issue-comment
448
490
  with:
449
491
  token: ${{ steps.app-token.outputs.token }}
@@ -478,36 +520,17 @@ jobs:
478
520
  with:
479
521
  client-id: ${{ secrets.BOT_APP_ID }}
480
522
  private-key: ${{ secrets.BOT_PRIVATE_KEY }}
523
+ # The shared rule, which this worker used to carry its own copy of. It repeats a run that died
524
+ # young, and now also one that was killed after a long time: the runner's VM taken away
525
+ # mid-job, or the AI gateway answering nothing and ending the engine one turn after it began.
481
526
  - name: Decide whether this failure is worth repeating
482
527
  id: decide
483
- env:
484
- GH_TOKEN: ${{ github.token }}
485
- REPO: ${{ github.repository }}
486
- RUN_ID: ${{ github.run_id }}
487
- ATTEMPTS: ${{ inputs.attempts_so_far || '0' }}
488
- PARK_AT: ${{ env.PARK_AT_ATTEMPT }}
489
- UNDER_MINUTES: ${{ env.RETRY_UNDER_MINUTES }}
490
- run: |
491
- set -euo pipefail
492
- # The agent job belongs to this same run: a called workflow shares the caller's run id.
493
- read -r started finished <<<"$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
494
- --jq '[.jobs[] | select(.name | endswith("agent"))] | last // empty
495
- | "\(.started_at // "") \(.completed_at // "")"')"
496
- minutes=-1
497
- if [ -n "${started:-}" ] && [ -n "${finished:-}" ]; then
498
- minutes=$(( ( $(date -u -d "$finished" +%s) - $(date -u -d "$started" +%s) ) / 60 ))
499
- fi
500
- retry=false
501
- # An unknown duration is treated as a long run: never retry on a guess.
502
- if [ "$minutes" -ge 0 ] && [ "$minutes" -lt "$UNDER_MINUTES" ] && [ "$ATTEMPTS" -lt "$PARK_AT" ]; then
503
- retry=true
504
- fi
505
- {
506
- echo "retry=$retry"
507
- echo "next=$((ATTEMPTS + 1))"
508
- echo "minutes=$minutes"
509
- } >> "$GITHUB_OUTPUT"
510
- echo "agent job ran for ${minutes}m; attempts so far ${ATTEMPTS}; retry=${retry}"
528
+ uses: ./.github/actions/decide-agent-retry
529
+ with:
530
+ token: ${{ github.token }}
531
+ attempts-so-far: ${{ inputs.attempts_so_far || '0' }}
532
+ park-at: ${{ env.PARK_AT_ATTEMPT }}
533
+ under-minutes: ${{ env.RETRY_UNDER_MINUTES }}
511
534
  # The attempt is recorded before any label moves, so a failure in the steps below leaves a
512
535
  # run that can be counted rather than an issue released with nothing to show for it.
513
536
  - name: Report the failed attempt
@@ -518,7 +541,7 @@ jobs:
518
541
  issue-number: ${{ inputs.issue-number }}
519
542
  body: |
520
543
  ${{ env.ATTEMPT_MARKER }}
521
- Attempt ${{ steps.decide.outputs.next }} of ${{ env.MAX_ATTEMPTS }} ended after ${{ steps.decide.outputs.minutes }} minutes, before the run could produce an answer. That is what a provider outage looks like, so this is being retried.
544
+ Attempt ${{ steps.decide.outputs.next }} of ${{ env.MAX_ATTEMPTS }} ended after ${{ steps.decide.outputs.minutes }} minutes without an answer (${{ steps.decide.outputs.cause }}). That is what a provider or runner outage looks like, not a verdict on the work, so this is being retried.
522
545
  The issue keeps `implement`.
523
546
  [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
524
547
  - name: Release the reservation for the retry
@@ -703,6 +726,28 @@ timeout-minutes: 180
703
726
  phase skill it names, in order, and do not explore the code yourself where it says a phase
704
727
  is skipped.
705
728
 
729
+ **The pipeline is these skill loads, in this order, each a call to the Skill tool.** A run
730
+ that edits source files without having made them has skipped the pipeline, whatever it
731
+ wrote instead:
732
+
733
+ 1. `pc-plan-goal`, then Phase 0 as it describes.
734
+ 2. `pc-plan-propose`, even when the issue is refined. It is what turns the issue into
735
+ `openspec/changes/<id>/tasks.md` with every task annotated
736
+ `<!-- agent: <engineer>.<tier>, depends_on: [...], touches: [...] -->`. **You never write
737
+ `proposal.md` or `tasks.md` yourself.** A hand-written `tasks.md` carries no annotations,
738
+ and without them `pc-plan-apply` has no workers to spawn, so the whole change is done by
739
+ you alone, in sequence, on one model. That is the failure this step exists to prevent.
740
+ 3. `pc-plan-apply`. The tasks are implemented by the subagents it spawns, in waves, each on
741
+ the model tier its annotation names. **In this phase you do not edit source files
742
+ yourself.** If it stops because a worker cannot be resolved, report that with
743
+ `report_incomplete`, naming the task and the missing worker; do not do the task yourself
744
+ to get past it.
745
+ 4. `pc-repo-verify`, then `pc-plan-archive`, as `pc-plan-goal` directs.
746
+
747
+ Before the first edit to a source file, check that
748
+ `openspec/changes/<id>/tasks.md` exists and that its tasks carry the annotation above. If it
749
+ does not, you have not run `pc-plan-propose`: load it now.
750
+
706
751
  a. `branch` is the output mode this sandbox needs: the branch is kept, nothing is merged and
707
752
  nothing is pushed. Without it the skill merges into the local default branch and deletes
708
753
  the feature branch, and step 6 below then opens a pull request from a branch that is