@plainconceptsplatform/workflows 1.2.3 → 1.3.1

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.
Files changed (45) hide show
  1. package/dist/route-processing.js +1 -0
  2. package/loops/actions/add-issue-labels/action.yml +50 -50
  3. package/loops/actions/agent-output.cjs +17 -17
  4. package/loops/actions/apply-agent-bundle/action.yml +24 -24
  5. package/loops/actions/apply-agent-comments/action.yml +42 -42
  6. package/loops/actions/apply-agent-labels/action.yml +55 -55
  7. package/loops/actions/apply-agent-output/action.yml +108 -108
  8. package/loops/actions/assess-blast-radius/action.yml +47 -8
  9. package/loops/actions/assess-blast-radius/assess-blast-radius.sh +27 -5
  10. package/loops/actions/assess-blast-radius/migration-is-additive.sh +38 -0
  11. package/loops/actions/classify-route/action.yml +2 -1
  12. package/loops/actions/classify-route/classify-route.sh +7 -1
  13. package/loops/actions/cleanup-artifacts/action.yml +91 -91
  14. package/loops/actions/close-agent-issues/action.yml +43 -43
  15. package/loops/actions/create-agent-issues/action.yml +52 -52
  16. package/loops/actions/create-issue-comment/action.yml +29 -29
  17. package/loops/actions/download-agent-output/action.yml +53 -53
  18. package/loops/actions/link-pr-to-issue/action.yml +40 -40
  19. package/loops/actions/list-open-issues/action.yml +33 -33
  20. package/loops/actions/load-issue-context/action.yml +45 -45
  21. package/loops/actions/merge-agent-pr/action.yml +49 -49
  22. package/loops/actions/push-agent-branch/action.yml +45 -45
  23. package/loops/actions/remove-issue-labels/action.yml +37 -37
  24. package/loops/actions/update-agent-issues/action.yml +58 -58
  25. package/loops/actions/validate-merge-gate-output/action.yml +6 -1
  26. package/loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh +9 -2
  27. package/loops/actions/validate-review-output/action.yml +35 -35
  28. package/loops/actions/validate-triage-output/action.yml +36 -36
  29. package/loops/actions/verify-composite-actions/action.yml +9 -9
  30. package/loops/actions/verify-refine-output/action.yml +9 -9
  31. package/loops/actions/verify-route-matrix/action.yml +9 -9
  32. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +149 -29
  33. package/loops/docs/diagrams.md +26 -11
  34. package/loops/templates/agentics/agentics-maintenance.yml +121 -121
  35. package/loops/templates/issues/bug_report.yml +109 -109
  36. package/loops/templates/issues/feature_request.yml +75 -75
  37. package/loops/templates/release/github-release.yml +30 -30
  38. package/loops/workflows/agent-apply-review.md +5 -0
  39. package/loops/workflows/agent-implement.md +6 -0
  40. package/loops/workflows/agent-merge-gate.md +100 -3
  41. package/loops/workflows/authorize-bot-work.yml +105 -105
  42. package/loops/workflows/evidence.yml +79 -4
  43. package/loops/workflows/shared/platform-defaults.md +23 -23
  44. package/loops/workflows/work-router.yml +97 -21
  45. package/package.json +1 -1
@@ -35,6 +35,10 @@ inputs:
35
35
  description: Lines changed at or above which the radius is MEDIUM.
36
36
  required: false
37
37
  default: '200'
38
+ ignore-paths:
39
+ description: Extended regular expression. Matching paths count toward neither size nor owner and sensitive paths; protected paths still match them.
40
+ required: false
41
+ default: ''
38
42
  outputs:
39
43
  level:
40
44
  description: "low, medium or high."
@@ -64,11 +68,17 @@ outputs:
64
68
  description: CODEOWNERS entries covering the changed paths, space separated. Empty when the repository has no CODEOWNERS.
65
69
  value: ${{ steps.assess.outputs.required_owners }}
66
70
  files_changed:
67
- description: Number of files in the diff.
71
+ description: Number of files that count toward size, ignored paths excluded.
68
72
  value: ${{ steps.assess.outputs.files_changed }}
69
73
  lines_changed:
70
- description: Additions plus deletions.
74
+ description: Additions plus deletions of the files that count toward size.
71
75
  value: ${{ steps.assess.outputs.lines_changed }}
76
+ files_total:
77
+ description: Number of files in the whole diff.
78
+ value: ${{ steps.assess.outputs.files_total }}
79
+ lines_total:
80
+ description: Additions plus deletions of the whole diff.
81
+ value: ${{ steps.assess.outputs.lines_total }}
72
82
  runs:
73
83
  using: composite
74
84
  steps:
@@ -86,22 +96,51 @@ runs:
86
96
  HIGH_LINES: ${{ inputs.high-lines }}
87
97
  MEDIUM_FILES: ${{ inputs.medium-files }}
88
98
  MEDIUM_LINES: ${{ inputs.medium-lines }}
99
+ IGNORE_PATHS: ${{ inputs.ignore-paths }}
89
100
  run: |
90
101
  set -euo pipefail
91
102
  # One API call. The job this replaced already paid for it to grep PROTECTED_PATHS, and
92
103
  # the additions and deletions come back in the same payload, so the diff shape costs
93
104
  # nothing extra.
94
105
  gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files?per_page=100" \
95
- --jq '[.[] | {filename, additions, deletions}]' > /tmp/blast-files.json
106
+ --jq '[.[] | {filename, status, additions, deletions, patch}]' | jq -s 'add // []' > /tmp/blast-files.json
107
+
108
+ all_files=$(jq -r '.[].filename' /tmp/blast-files.json)
109
+ files_total=$(jq -r 'length' /tmp/blast-files.json)
110
+ lines_total=$(jq -r '[.[] | .additions + .deletions] | add // 0' /tmp/blast-files.json)
111
+
112
+ # Tests, docs, specs, translations and generated code change no runtime behaviour, so they
113
+ # do not make a diff bigger. They stay in ALL_PATHS, so a protected path is never ignored.
114
+ if [ -n "$IGNORE_PATHS" ]; then
115
+ jq -c --arg re "$IGNORE_PATHS" '[.[] | select(.filename | test($re) | not)]' /tmp/blast-files.json > /tmp/blast-counted.json
116
+ else
117
+ cp /tmp/blast-files.json /tmp/blast-counted.json
118
+ fi
119
+ files=$(jq -r '.[].filename' /tmp/blast-counted.json)
120
+ files_changed=$(jq -r 'length' /tmp/blast-counted.json)
121
+ lines_changed=$(jq -r '[.[] | .additions + .deletions] | add // 0' /tmp/blast-counted.json)
96
122
 
97
- files=$(jq -r '.[].filename' /tmp/blast-files.json)
98
- files_changed=$(jq -r 'length' /tmp/blast-files.json)
99
- lines_changed=$(jq -r '[.[] | .additions + .deletions] | add // 0' /tmp/blast-files.json)
123
+ # A new EF Core migration whose Up() only adds schema. An edited one never qualifies:
124
+ # changing a migration that may already have run is exactly what needs its owner.
125
+ additive=""
126
+ while IFS= read -r candidate; do
127
+ [ -n "$candidate" ] || continue
128
+ if jq -r --arg f "$candidate" '.[] | select(.filename == $f) | .patch // ""' /tmp/blast-counted.json |
129
+ bash "${{ github.action_path }}/migration-is-additive.sh" >/dev/null; then
130
+ additive="${additive}${additive:+$'\n'}${candidate}"
131
+ fi
132
+ done < <(jq -r '.[] | select(.status == "added") | .filename
133
+ | select(test("(^|/)[Mm]igrations/[^/]+[.]cs$"))
134
+ | select(test("([.]Designer|ModelSnapshot)[.]cs$") | not)' /tmp/blast-counted.json)
100
135
 
101
- bash "${{ github.action_path }}/assess-blast-radius.sh" \
136
+ ALL_PATHS="$all_files" ADDITIVE_MIGRATIONS="$additive" \
137
+ IGNORED_FILES=$((files_total - files_changed)) IGNORED_LINES=$((lines_total - lines_changed)) \
138
+ bash "${{ github.action_path }}/assess-blast-radius.sh" \
102
139
  "$files_changed" "$lines_changed" <<<"$files" > /tmp/blast-out.env
103
140
 
104
141
  cat /tmp/blast-out.env >> "$GITHUB_OUTPUT"
142
+ echo "files_total=$files_total" >> "$GITHUB_OUTPUT"
143
+ echo "lines_total=$lines_total" >> "$GITHUB_OUTPUT"
105
144
 
106
145
  # CODEOWNERS refines who is asked; it never decides whether anyone is. A repository
107
146
  # without the file still gets an owner disposition from OWNER_PATHS, and the comment
@@ -139,7 +178,7 @@ runs:
139
178
  esac
140
179
  # An unanchored CODEOWNERS pattern matches at any depth, an anchored one only at the root.
141
180
  if [ "$anchored" -eq 1 ]; then regex="^${regex}"; else regex="(^|/)${regex}"; fi
142
- if printf '%s\n' "$files" | grep -qE "$regex"; then
181
+ if printf '%s\n' "$all_files" | grep -qE "$regex"; then
143
182
  owners="$owners $rest"
144
183
  fi
145
184
  done < "$candidate"
@@ -2,7 +2,9 @@
2
2
  # Managed by @plainconceptsplatform/workflows. Source: loops/actions/assess-blast-radius/assess-blast-radius.sh. Update with `workflows update --force`; consumer edits may be overwritten.
3
3
  # Classify a pull request's blast radius from its changed paths and diff shape.
4
4
  #
5
- # Reads the changed paths on stdin, one per line. Prints GITHUB_OUTPUT lines on stdout.
5
+ # Reads the changed paths that count toward the diff's size on stdin, one per line. Prints
6
+ # GITHUB_OUTPUT lines on stdout. ALL_PATHS, when set, is every changed path, ignored ones included:
7
+ # protected paths are matched against it, so ignoring a path never unprotects it.
6
8
  # Deliberately model-free: the point of this file is that the most consequential input to the
7
9
  # merge decision is computed from facts a shell can check, before any agent reads the diff.
8
10
  #
@@ -21,8 +23,13 @@ HIGH_FILES="${HIGH_FILES:-20}"
21
23
  HIGH_LINES="${HIGH_LINES:-800}"
22
24
  MEDIUM_FILES="${MEDIUM_FILES:-5}"
23
25
  MEDIUM_LINES="${MEDIUM_LINES:-200}"
26
+ # Added migrations the caller proved only add schema (migration-is-additive.sh), one per line.
27
+ ADDITIVE_MIGRATIONS="${ADDITIVE_MIGRATIONS:-}"
28
+ IGNORED_FILES="${IGNORED_FILES:-0}"
29
+ IGNORED_LINES="${IGNORED_LINES:-0}"
24
30
 
25
31
  files="$(cat)"
32
+ all_paths="${ALL_PATHS:-$files}"
26
33
 
27
34
  # An empty regex matches every line in grep -E, which would mark every pull request protected.
28
35
  # A list nobody configured must match nothing, not everything.
@@ -34,9 +41,9 @@ files="$(cat)"
34
41
  # before any of them is used: a `match` failure cannot report itself, because `match` runs
35
42
  # inside a command substitution and anything it sets dies with the subshell.
36
43
  match() {
37
- local pattern="$1"
44
+ local pattern="$1" paths="${2-$files}"
38
45
  [ -n "$pattern" ] || return 0
39
- printf '%s\n' "$files" | grep -E "$pattern" || true
46
+ printf '%s\n' "$paths" | grep -E "$pattern" || true
40
47
  }
41
48
 
42
49
  # Compile each configured pattern against nothing. grep exits 2 when the regex is bad, which is
@@ -50,14 +57,22 @@ for name in PROTECTED_PATHS OWNER_PATHS SENSITIVE_PATHS; do
50
57
  fi
51
58
  done
52
59
 
53
- protected_hits="$(match "$PROTECTED_PATHS")"
60
+ protected_hits="$(match "$PROTECTED_PATHS" "$all_paths")"
54
61
  owner_hits="$(match "$OWNER_PATHS")"
55
62
  sensitive_hits="$(match "$SENSITIVE_PATHS")"
56
63
 
64
+ # An additive migration loses no data, so it asks for a second look (medium) rather than its
65
+ # owner. Every other owner path still forces high.
66
+ additive_hits=""
67
+ if [ -n "$ADDITIVE_MIGRATIONS" ] && [ -n "$owner_hits" ]; then
68
+ additive_hits="$(printf '%s\n' "$owner_hits" | grep -Fxf <(printf '%s\n' "$ADDITIVE_MIGRATIONS") || true)"
69
+ owner_hits="$(printf '%s\n' "$owner_hits" | grep -vFxf <(printf '%s\n' "$ADDITIVE_MIGRATIONS") || true)"
70
+ fi
71
+
57
72
  # A threshold that is not a number turns its comparison into a shell error that `set -e` does
58
73
  # not catch, because the test is an `if` condition: `HIGH_FILES=twenty` printed a diagnostic to
59
74
  # stderr and quietly produced `level=low` for a diff of any size.
60
- for threshold in HIGH_FILES HIGH_LINES MEDIUM_FILES MEDIUM_LINES files_changed lines_changed; do
75
+ for threshold in HIGH_FILES HIGH_LINES MEDIUM_FILES MEDIUM_LINES files_changed lines_changed IGNORED_FILES IGNORED_LINES; do
61
76
  case "${!threshold}" in
62
77
  ""|*[!0-9]*)
63
78
  echo "assess-blast-radius: ${threshold} must be a whole number, got '${!threshold}'" >&2
@@ -92,6 +107,10 @@ if [ "$lines_changed" -ge "$HIGH_LINES" ]; then
92
107
  fi
93
108
 
94
109
  if [ "$level" != high ]; then
110
+ if [ -n "$additive_hits" ]; then
111
+ level=medium
112
+ add_signal "additive migrations, no owner needed ($(printf '%s\n' "$additive_hits" | tr '\n' ' ' | sed 's/ $//'))"
113
+ fi
95
114
  if [ -n "$sensitive_hits" ]; then
96
115
  level=medium
97
116
  add_signal "sensitive paths touched ($(printf '%s\n' "$sensitive_hits" | tr '\n' ' ' | sed 's/ $//'))"
@@ -107,6 +126,9 @@ if [ "$level" != high ]; then
107
126
  fi
108
127
 
109
128
  [ -n "$signals" ] || signals="no blast-radius signal fired: $files_changed files, $lines_changed lines, no configured path matched"
129
+ if [ "$IGNORED_FILES" -gt 0 ]; then
130
+ add_signal "not counted toward size: $IGNORED_FILES files, $IGNORED_LINES lines (tests, docs, specs, translations, generated code)"
131
+ fi
110
132
 
111
133
  # A fixed heredoc delimiter is a fail-open here. Every multi-line value below is built from
112
134
  # paths the pull request chose, so a path that is exactly the delimiter closes its block early
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env bash
2
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/assess-blast-radius/migration-is-additive.sh. Update with `workflows update --force`; consumer edits may be overwritten.
3
+ # Decide whether a newly added EF Core migration only adds schema. Reads the file's diff patch on
4
+ # stdin; exits 0 and prints "additive" when it does, exits 1 and prints the reason when it does not.
5
+ #
6
+ # Only Up() is read: Down() of an additive migration is all drops by construction. Anything that
7
+ # can lose or rewrite data, or a structure this cannot follow, is not additive.
8
+
9
+ set -euo pipefail
10
+
11
+ awk '
12
+ /^\+/ { line = substr($0, 2) }
13
+ !/^\+/ { next }
14
+ line ~ /void[ \t]+Up[ \t]*\(/ { in_up = 1; seen_up = 1; next }
15
+ line ~ /void[ \t]+Down[ \t]*\(/ { in_up = 0; next }
16
+ !in_up { next }
17
+ line ~ /\.(Drop[A-Za-z]*|Rename[A-Za-z]*|Alter[A-Za-z]*|Sql|DeleteData|UpdateData|RestartSequence)[ \t]*[(<]/ {
18
+ match(line, /\.[A-Za-z]+[ \t]*[(<]/)
19
+ reason = "Up() calls " substr(line, RSTART + 1, RLENGTH - 1)
20
+ exit
21
+ }
22
+ line ~ /\.AddColumn[ \t]*</ { in_column = 1; column = "" }
23
+ in_column {
24
+ column = column " " line
25
+ if (line ~ /\);/) {
26
+ in_column = 0
27
+ if (column ~ /nullable:[ \t]*false/ && column !~ /defaultValue/) {
28
+ reason = "Up() adds a required column with no default"
29
+ exit
30
+ }
31
+ }
32
+ }
33
+ END {
34
+ if (reason == "" && !seen_up) reason = "no Up() method found in the added lines"
35
+ if (reason != "") { print reason; exit 1 }
36
+ print "additive"
37
+ }
38
+ '
@@ -56,6 +56,7 @@ runs:
56
56
  PR_MERGED: ${{ github.event.pull_request.merged }}
57
57
  COMMENT_ON_PR: ${{ github.event.issue.pull_request != '' }}
58
58
  COMMENT_SENDER_TYPE: ${{ github.event.comment.user.type }}
59
+ REVIEW_STATE: ${{ github.event.review.state }}
59
60
  ACTOR: ${{ github.actor }}
60
61
  RUN_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
61
62
  RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
@@ -84,7 +85,7 @@ runs:
84
85
 
85
86
  - name: Resolve the issue the pull request closes
86
87
  id: linked-issue
87
- if: steps.classify.outputs.route == 'merge-gate' || steps.classify.outputs.route == 'apply-review'
88
+ if: contains(fromJson('["merge-gate","apply-review","merge-approved"]'), steps.classify.outputs.route)
88
89
  shell: bash
89
90
  env:
90
91
  GH_TOKEN: ${{ inputs.token }}
@@ -135,7 +135,13 @@ classify_route() {
135
135
  ;;
136
136
 
137
137
  pull_request_review_comment | pull_request_review)
138
- route="apply-review"
138
+ # An approval is a decision, not feedback: the router merges the pull request when the
139
+ # gate had handed it to a person (merge-approved checks the rest).
140
+ if [ "${EVENT:-}" = "pull_request_review" ] && [ "${REVIEW_STATE:-}" = "approved" ]; then
141
+ route="merge-approved"
142
+ else
143
+ route="apply-review"
144
+ fi
139
145
  pr_number="${EVENT_PR_NUMBER:-}"
140
146
  ;;
141
147
 
@@ -1,91 +1,91 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/cleanup-artifacts/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Clean up artifacts
3
- description: Delete workflow artifacts older than the configured retention period.
4
- inputs:
5
- token:
6
- description: GitHub token with actions write access.
7
- required: true
8
- artifact-retention-days:
9
- description: Retention period in days. Invalid or empty values use 3.
10
- required: false
11
- default: ''
12
- runs:
13
- using: composite
14
- steps:
15
- - name: Delete expired artifacts
16
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
17
- env:
18
- ARTIFACT_RETENTION_DAYS: ${{ inputs.artifact-retention-days }}
19
- with:
20
- github-token: ${{ inputs.token }}
21
- script: |
22
- const parsedDays = Number.parseInt(process.env.ARTIFACT_RETENTION_DAYS, 10);
23
- const days = Number.isInteger(parsedDays) && parsedDays > 0 ? parsedDays : 3;
24
- const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
25
-
26
- core.info(`Retention: ${days} days (cutoff ${cutoff.toISOString()})`);
27
-
28
- const artifacts = await github.paginate(github.rest.actions.listArtifactsForRepo, {
29
- ...context.repo,
30
- per_page: 100,
31
- });
32
-
33
- const expired = artifacts.filter((artifact) => new Date(artifact.created_at) < cutoff);
34
-
35
- let deleted = 0;
36
- const failed = [];
37
-
38
- for (const artifact of expired) {
39
- // GitHub answers a delete with a 5xx now and then. Three tries, because the
40
- // alternative is a red daily run over one artifact that tomorrow's run would
41
- // have removed anyway.
42
- let lastError;
43
- for (let attempt = 1; attempt <= 3; attempt += 1) {
44
- try {
45
- await github.rest.actions.deleteArtifact({
46
- ...context.repo,
47
- artifact_id: artifact.id,
48
- });
49
-
50
- core.info(`Deleted ${artifact.name} (created ${artifact.created_at})`);
51
- deleted += 1;
52
- lastError = undefined;
53
- break;
54
- } catch (error) {
55
- lastError = error;
56
- if (attempt < 3) await new Promise((wait) => setTimeout(wait, attempt * 2000));
57
- }
58
- }
59
-
60
- if (lastError) {
61
- core.warning(
62
- `Could not delete ${artifact.name} (${artifact.id}) after 3 tries: ${lastError.message}`,
63
- );
64
- failed.push({ name: artifact.name, status: lastError.status });
65
- }
66
- }
67
-
68
- await core.summary
69
- .addHeading('Artifact cleanup', 2)
70
- .addList([
71
- `Retention: ${days} days (cutoff ${cutoff.toISOString()})`,
72
- `Deleted: ${deleted}`,
73
- `Failed to delete: ${failed.length}`,
74
- `Within retention: ${artifacts.length - expired.length}`,
75
- ])
76
- .write();
77
-
78
- // Deleting nothing at all while expired artifacts exist is a permission or
79
- // configuration problem, and has to be loud. One stubborn artifact among many is
80
- // the API having a bad minute: tomorrow's run picks it up, and failing here only
81
- // teaches people to ignore a red cleanup. A 4xx is never transient, so it fails
82
- // whatever else succeeded.
83
- const names = failed.map((entry) => entry.name).join(', ');
84
- const structural = failed.some((entry) => entry.status && entry.status < 500);
85
- if (failed.length > 0 && (deleted === 0 || structural)) {
86
- core.setFailed(`${failed.length} artifact(s) could not be deleted: ${names}`);
87
- } else if (failed.length > 0) {
88
- core.warning(
89
- `${failed.length} artifact(s) survived this run and will be retried tomorrow: ${names}`,
90
- );
91
- }
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/cleanup-artifacts/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Clean up artifacts
3
+ description: Delete workflow artifacts older than the configured retention period.
4
+ inputs:
5
+ token:
6
+ description: GitHub token with actions write access.
7
+ required: true
8
+ artifact-retention-days:
9
+ description: Retention period in days. Invalid or empty values use 3.
10
+ required: false
11
+ default: ''
12
+ runs:
13
+ using: composite
14
+ steps:
15
+ - name: Delete expired artifacts
16
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
17
+ env:
18
+ ARTIFACT_RETENTION_DAYS: ${{ inputs.artifact-retention-days }}
19
+ with:
20
+ github-token: ${{ inputs.token }}
21
+ script: |
22
+ const parsedDays = Number.parseInt(process.env.ARTIFACT_RETENTION_DAYS, 10);
23
+ const days = Number.isInteger(parsedDays) && parsedDays > 0 ? parsedDays : 3;
24
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
25
+
26
+ core.info(`Retention: ${days} days (cutoff ${cutoff.toISOString()})`);
27
+
28
+ const artifacts = await github.paginate(github.rest.actions.listArtifactsForRepo, {
29
+ ...context.repo,
30
+ per_page: 100,
31
+ });
32
+
33
+ const expired = artifacts.filter((artifact) => new Date(artifact.created_at) < cutoff);
34
+
35
+ let deleted = 0;
36
+ const failed = [];
37
+
38
+ for (const artifact of expired) {
39
+ // GitHub answers a delete with a 5xx now and then. Three tries, because the
40
+ // alternative is a red daily run over one artifact that tomorrow's run would
41
+ // have removed anyway.
42
+ let lastError;
43
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
44
+ try {
45
+ await github.rest.actions.deleteArtifact({
46
+ ...context.repo,
47
+ artifact_id: artifact.id,
48
+ });
49
+
50
+ core.info(`Deleted ${artifact.name} (created ${artifact.created_at})`);
51
+ deleted += 1;
52
+ lastError = undefined;
53
+ break;
54
+ } catch (error) {
55
+ lastError = error;
56
+ if (attempt < 3) await new Promise((wait) => setTimeout(wait, attempt * 2000));
57
+ }
58
+ }
59
+
60
+ if (lastError) {
61
+ core.warning(
62
+ `Could not delete ${artifact.name} (${artifact.id}) after 3 tries: ${lastError.message}`,
63
+ );
64
+ failed.push({ name: artifact.name, status: lastError.status });
65
+ }
66
+ }
67
+
68
+ await core.summary
69
+ .addHeading('Artifact cleanup', 2)
70
+ .addList([
71
+ `Retention: ${days} days (cutoff ${cutoff.toISOString()})`,
72
+ `Deleted: ${deleted}`,
73
+ `Failed to delete: ${failed.length}`,
74
+ `Within retention: ${artifacts.length - expired.length}`,
75
+ ])
76
+ .write();
77
+
78
+ // Deleting nothing at all while expired artifacts exist is a permission or
79
+ // configuration problem, and has to be loud. One stubborn artifact among many is
80
+ // the API having a bad minute: tomorrow's run picks it up, and failing here only
81
+ // teaches people to ignore a red cleanup. A 4xx is never transient, so it fails
82
+ // whatever else succeeded.
83
+ const names = failed.map((entry) => entry.name).join(', ');
84
+ const structural = failed.some((entry) => entry.status && entry.status < 500);
85
+ if (failed.length > 0 && (deleted === 0 || structural)) {
86
+ core.setFailed(`${failed.length} artifact(s) could not be deleted: ${names}`);
87
+ } else if (failed.length > 0) {
88
+ core.warning(
89
+ `${failed.length} artifact(s) survived this run and will be retried tomorrow: ${names}`,
90
+ );
91
+ }
@@ -1,43 +1,43 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/close-agent-issues/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Close agent issues
3
- description: Close every close_issue item from agent_output.json.
4
- inputs:
5
- output-file:
6
- description: Path to agent_output.json.
7
- required: true
8
- token:
9
- description: GitHub token with issues:write.
10
- required: true
11
- runs:
12
- using: composite
13
- steps:
14
- - name: Close issues
15
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
16
- env:
17
- AGENT_OUTPUT_LIB: ${{ github.action_path }}/../agent-output.cjs
18
- OUTPUT_FILE: ${{ inputs.output-file }}
19
- with:
20
- github-token: ${{ inputs.token }}
21
- script: |
22
- const { readAgentItems } = require(process.env.AGENT_OUTPUT_LIB);
23
- const items = readAgentItems(process.env.OUTPUT_FILE, 'close_issue');
24
-
25
- let closed = 0;
26
-
27
- for (const item of items) {
28
- if (!item.item_number) {
29
- core.warning(`close_issue item has no item_number, skipping: ${JSON.stringify(item)}`);
30
- continue;
31
- }
32
-
33
- await github.rest.issues.update({
34
- ...context.repo,
35
- issue_number: Number(item.item_number),
36
- state: 'closed',
37
- state_reason: item.state_reason === 'not_planned' ? 'not_planned' : 'completed',
38
- });
39
-
40
- closed += 1;
41
- }
42
-
43
- core.info(`Closed ${closed} issue(s).`);
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/close-agent-issues/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Close agent issues
3
+ description: Close every close_issue item from agent_output.json.
4
+ inputs:
5
+ output-file:
6
+ description: Path to agent_output.json.
7
+ required: true
8
+ token:
9
+ description: GitHub token with issues:write.
10
+ required: true
11
+ runs:
12
+ using: composite
13
+ steps:
14
+ - name: Close issues
15
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
16
+ env:
17
+ AGENT_OUTPUT_LIB: ${{ github.action_path }}/../agent-output.cjs
18
+ OUTPUT_FILE: ${{ inputs.output-file }}
19
+ with:
20
+ github-token: ${{ inputs.token }}
21
+ script: |
22
+ const { readAgentItems } = require(process.env.AGENT_OUTPUT_LIB);
23
+ const items = readAgentItems(process.env.OUTPUT_FILE, 'close_issue');
24
+
25
+ let closed = 0;
26
+
27
+ for (const item of items) {
28
+ if (!item.item_number) {
29
+ core.warning(`close_issue item has no item_number, skipping: ${JSON.stringify(item)}`);
30
+ continue;
31
+ }
32
+
33
+ await github.rest.issues.update({
34
+ ...context.repo,
35
+ issue_number: Number(item.item_number),
36
+ state: 'closed',
37
+ state_reason: item.state_reason === 'not_planned' ? 'not_planned' : 'completed',
38
+ });
39
+
40
+ closed += 1;
41
+ }
42
+
43
+ core.info(`Closed ${closed} issue(s).`);
@@ -1,52 +1,52 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/create-agent-issues/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Create agent issues
3
- description: Create every create_issue item from agent_output.json.
4
- inputs:
5
- output-file:
6
- description: Path to agent_output.json.
7
- required: true
8
- token:
9
- description: GitHub token with issues:write.
10
- required: true
11
- outputs:
12
- created-count:
13
- description: Number of issues created.
14
- value: ${{ steps.create.outputs.created-count }}
15
- first-issue-number:
16
- description: Issue number of the first issue created, or empty.
17
- value: ${{ steps.create.outputs.first-issue-number }}
18
- runs:
19
- using: composite
20
- steps:
21
- - name: Create issues
22
- id: create
23
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
24
- env:
25
- AGENT_OUTPUT_LIB: ${{ github.action_path }}/../agent-output.cjs
26
- OUTPUT_FILE: ${{ inputs.output-file }}
27
- with:
28
- github-token: ${{ inputs.token }}
29
- script: |
30
- const { readAgentItems } = require(process.env.AGENT_OUTPUT_LIB);
31
- const items = readAgentItems(process.env.OUTPUT_FILE, 'create_issue');
32
-
33
- const created = [];
34
-
35
- for (const item of items) {
36
- if (!item.title) {
37
- core.warning(`create_issue item has no title, skipping: ${JSON.stringify(item)}`);
38
- continue;
39
- }
40
-
41
- const { data } = await github.rest.issues.create({
42
- ...context.repo,
43
- title: item.title,
44
- body: item.body ?? '',
45
- });
46
-
47
- created.push(data.number);
48
- core.info(`Created #${data.number}: ${item.title}`);
49
- }
50
-
51
- core.setOutput('created-count', String(created.length));
52
- core.setOutput('first-issue-number', created.length > 0 ? String(created[0]) : '');
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/create-agent-issues/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Create agent issues
3
+ description: Create every create_issue item from agent_output.json.
4
+ inputs:
5
+ output-file:
6
+ description: Path to agent_output.json.
7
+ required: true
8
+ token:
9
+ description: GitHub token with issues:write.
10
+ required: true
11
+ outputs:
12
+ created-count:
13
+ description: Number of issues created.
14
+ value: ${{ steps.create.outputs.created-count }}
15
+ first-issue-number:
16
+ description: Issue number of the first issue created, or empty.
17
+ value: ${{ steps.create.outputs.first-issue-number }}
18
+ runs:
19
+ using: composite
20
+ steps:
21
+ - name: Create issues
22
+ id: create
23
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
24
+ env:
25
+ AGENT_OUTPUT_LIB: ${{ github.action_path }}/../agent-output.cjs
26
+ OUTPUT_FILE: ${{ inputs.output-file }}
27
+ with:
28
+ github-token: ${{ inputs.token }}
29
+ script: |
30
+ const { readAgentItems } = require(process.env.AGENT_OUTPUT_LIB);
31
+ const items = readAgentItems(process.env.OUTPUT_FILE, 'create_issue');
32
+
33
+ const created = [];
34
+
35
+ for (const item of items) {
36
+ if (!item.title) {
37
+ core.warning(`create_issue item has no title, skipping: ${JSON.stringify(item)}`);
38
+ continue;
39
+ }
40
+
41
+ const { data } = await github.rest.issues.create({
42
+ ...context.repo,
43
+ title: item.title,
44
+ body: item.body ?? '',
45
+ });
46
+
47
+ created.push(data.number);
48
+ core.info(`Created #${data.number}: ${item.title}`);
49
+ }
50
+
51
+ core.setOutput('created-count', String(created.length));
52
+ core.setOutput('first-issue-number', created.length > 0 ? String(created[0]) : '');