@urateam/core 0.1.32 → 0.1.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/audit-immutability.test.js +50 -1
- package/dist/__tests__/audit-immutability.test.js.map +1 -1
- package/dist/__tests__/auth-monitor.test.d.ts +2 -0
- package/dist/__tests__/auth-monitor.test.d.ts.map +1 -0
- package/dist/__tests__/auth-monitor.test.js +253 -0
- package/dist/__tests__/auth-monitor.test.js.map +1 -0
- package/dist/__tests__/bec-186-repro.test.d.ts +16 -0
- package/dist/__tests__/bec-186-repro.test.d.ts.map +1 -0
- package/dist/__tests__/bec-186-repro.test.js +223 -0
- package/dist/__tests__/bec-186-repro.test.js.map +1 -0
- package/dist/__tests__/db-migrations.test.d.ts +2 -0
- package/dist/__tests__/db-migrations.test.d.ts.map +1 -0
- package/dist/__tests__/db-migrations.test.js +237 -0
- package/dist/__tests__/db-migrations.test.js.map +1 -0
- package/dist/__tests__/executor-issue-id.test.js +2 -0
- package/dist/__tests__/executor-issue-id.test.js.map +1 -1
- package/dist/__tests__/pm-scheduler.test.js +59 -0
- package/dist/__tests__/pm-scheduler.test.js.map +1 -1
- package/dist/__tests__/post-fanout-comments.test.js +36 -0
- package/dist/__tests__/post-fanout-comments.test.js.map +1 -1
- package/dist/__tests__/preflight-claude-auth.test.d.ts +2 -0
- package/dist/__tests__/preflight-claude-auth.test.d.ts.map +1 -0
- package/dist/__tests__/preflight-claude-auth.test.js +36 -0
- package/dist/__tests__/preflight-claude-auth.test.js.map +1 -0
- package/dist/__tests__/resolve-claude-auth.test.d.ts +2 -0
- package/dist/__tests__/resolve-claude-auth.test.d.ts.map +1 -0
- package/dist/__tests__/resolve-claude-auth.test.js +129 -0
- package/dist/__tests__/resolve-claude-auth.test.js.map +1 -0
- package/dist/__tests__/runner-retry-strategies.test.d.ts +23 -0
- package/dist/__tests__/runner-retry-strategies.test.d.ts.map +1 -0
- package/dist/__tests__/runner-retry-strategies.test.js +274 -0
- package/dist/__tests__/runner-retry-strategies.test.js.map +1 -0
- package/dist/__tests__/scratch-file-guard.test.d.ts +2 -0
- package/dist/__tests__/scratch-file-guard.test.d.ts.map +1 -0
- package/dist/__tests__/scratch-file-guard.test.js +144 -0
- package/dist/__tests__/scratch-file-guard.test.js.map +1 -0
- package/dist/__tests__/spec-vs-impl-gate.test.d.ts +2 -0
- package/dist/__tests__/spec-vs-impl-gate.test.d.ts.map +1 -0
- package/dist/__tests__/spec-vs-impl-gate.test.js +222 -0
- package/dist/__tests__/spec-vs-impl-gate.test.js.map +1 -0
- package/dist/__tests__/stage-models.test.js +4 -0
- package/dist/__tests__/stage-models.test.js.map +1 -1
- package/dist/__tests__/typecheck-gate.test.d.ts +2 -0
- package/dist/__tests__/typecheck-gate.test.d.ts.map +1 -0
- package/dist/__tests__/typecheck-gate.test.js +196 -0
- package/dist/__tests__/typecheck-gate.test.js.map +1 -0
- package/dist/audit/events.d.ts +52 -0
- package/dist/audit/events.d.ts.map +1 -1
- package/dist/audit/events.js +81 -0
- package/dist/audit/events.js.map +1 -1
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +8 -0
- package/dist/db/client.js.map +1 -1
- package/dist/db/migrations/postgres/014_missing_indexes.sql +28 -0
- package/dist/db/migrations/sqlite/013_missing_indexes.sql +28 -0
- package/dist/executor/auth-check.d.ts +39 -0
- package/dist/executor/auth-check.d.ts.map +1 -1
- package/dist/executor/auth-check.js +31 -0
- package/dist/executor/auth-check.js.map +1 -1
- package/dist/executor/auth-monitor.d.ts +40 -0
- package/dist/executor/auth-monitor.d.ts.map +1 -0
- package/dist/executor/auth-monitor.js +114 -0
- package/dist/executor/auth-monitor.js.map +1 -0
- package/dist/executor/executor.d.ts.map +1 -1
- package/dist/executor/executor.js +12 -26
- package/dist/executor/executor.js.map +1 -1
- package/dist/executor/index.d.ts +2 -0
- package/dist/executor/index.d.ts.map +1 -1
- package/dist/executor/index.js +2 -0
- package/dist/executor/index.js.map +1 -1
- package/dist/executor/review/post-fanout-comments.d.ts +8 -0
- package/dist/executor/review/post-fanout-comments.d.ts.map +1 -1
- package/dist/executor/review/post-fanout-comments.js +23 -3
- package/dist/executor/review/post-fanout-comments.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/notifier/composite.d.ts +1 -0
- package/dist/notifier/composite.d.ts.map +1 -1
- package/dist/notifier/composite.js +3 -0
- package/dist/notifier/composite.js.map +1 -1
- package/dist/notifier/linear.d.ts +2 -0
- package/dist/notifier/linear.d.ts.map +1 -1
- package/dist/notifier/linear.js +12 -2
- package/dist/notifier/linear.js.map +1 -1
- package/dist/pipeline/runner.d.ts.map +1 -1
- package/dist/pipeline/runner.js +152 -2
- package/dist/pipeline/runner.js.map +1 -1
- package/dist/pipeline/scratch-file-guard.d.ts +21 -0
- package/dist/pipeline/scratch-file-guard.d.ts.map +1 -0
- package/dist/pipeline/scratch-file-guard.js +155 -0
- package/dist/pipeline/scratch-file-guard.js.map +1 -0
- package/dist/pipeline/spec-vs-impl-gate.d.ts +49 -0
- package/dist/pipeline/spec-vs-impl-gate.d.ts.map +1 -0
- package/dist/pipeline/spec-vs-impl-gate.js +177 -0
- package/dist/pipeline/spec-vs-impl-gate.js.map +1 -0
- package/dist/pipeline/typecheck-gate.d.ts +34 -0
- package/dist/pipeline/typecheck-gate.d.ts.map +1 -0
- package/dist/pipeline/typecheck-gate.js +89 -0
- package/dist/pipeline/typecheck-gate.js.map +1 -0
- package/dist/pm/scheduler.d.ts.map +1 -1
- package/dist/pm/scheduler.js +19 -0
- package/dist/pm/scheduler.js.map +1 -1
- package/dist/release-manager/index.d.ts +2 -0
- package/dist/release-manager/index.d.ts.map +1 -1
- package/dist/release-manager/index.js +2 -0
- package/dist/release-manager/index.js.map +1 -1
- package/dist/release-manager/release-helpers.d.ts +112 -0
- package/dist/release-manager/release-helpers.d.ts.map +1 -0
- package/dist/release-manager/release-helpers.js +164 -0
- package/dist/release-manager/release-helpers.js.map +1 -0
- package/dist/release-manager/release-tick.d.ts +101 -0
- package/dist/release-manager/release-tick.d.ts.map +1 -0
- package/dist/release-manager/release-tick.js +374 -0
- package/dist/release-manager/release-tick.js.map +1 -0
- package/dist/release-manager/scheduler.d.ts +28 -3
- package/dist/release-manager/scheduler.d.ts.map +1 -1
- package/dist/release-manager/scheduler.js +41 -417
- package/dist/release-manager/scheduler.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3 -0
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +15 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +17 -0
- package/dist/types.js.map +1 -1
- package/dist/webhook/github-handler.d.ts +7 -1
- package/dist/webhook/github-handler.d.ts.map +1 -1
- package/dist/webhook/github-handler.js +82 -38
- package/dist/webhook/github-handler.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* release-helpers.ts
|
|
3
|
+
*
|
|
4
|
+
* Responsibility: persistence and Slack notification helpers for the release manager.
|
|
5
|
+
*
|
|
6
|
+
* Exports:
|
|
7
|
+
* - SlackPoster — interface for the injectable Slack client
|
|
8
|
+
* - SlackDedupState — mutable dedup counters passed between ticks
|
|
9
|
+
* - MAX_QA_RETRY_ATTEMPTS — threshold before escalating to a permanent skip reason
|
|
10
|
+
* - maybePostSlack — post to Slack with 24-hour same-reason dedup
|
|
11
|
+
* - persistDecision — write a release_decisions row
|
|
12
|
+
* - consumeApprovalRow — mark the most-recent fresh approval as consumed
|
|
13
|
+
* - getMaxAttemptCountForReason — query the highest attempt count for a (repo, branch, reason) triple
|
|
14
|
+
* - tryFileQaGapIssue — file a QA gap issue via Linear and handle transient errors
|
|
15
|
+
*/
|
|
16
|
+
import { and, desc, eq, isNull, max } from "drizzle-orm";
|
|
17
|
+
import { releaseApprovals, releaseDecisions } from "../db/schema.js";
|
|
18
|
+
import { logAuditEventUnchecked } from "../audit/writer.js";
|
|
19
|
+
import { slackPostFailedEvent } from "../audit/events.js";
|
|
20
|
+
import { createLogger } from "../logger.js";
|
|
21
|
+
import { fileGapIssue } from "../qa/gap.js";
|
|
22
|
+
const log = createLogger({ component: "ReleaseManager:helpers" });
|
|
23
|
+
/** 24-hour dedup window — same reason within this window is suppressed. */
|
|
24
|
+
const SLACK_DEDUP_WINDOW_MS = 24 * 3600 * 1000;
|
|
25
|
+
/**
|
|
26
|
+
* Maximum consecutive QA-related retry attempts before escalating to a
|
|
27
|
+
* permanent skip reason (e.g. `"qa_dispatch_error"` or `"qa_gap_file_error"`).
|
|
28
|
+
*/
|
|
29
|
+
export const MAX_QA_RETRY_ATTEMPTS = 3;
|
|
30
|
+
/**
|
|
31
|
+
* Post a Slack message, deduplicating same-reason posts within 24 hours.
|
|
32
|
+
*
|
|
33
|
+
* - When `currentSkipReason` is non-null: suppresses the post if the same
|
|
34
|
+
* reason was posted within `SLACK_DEDUP_WINDOW_MS`.
|
|
35
|
+
* - When `currentSkipReason` is null (fire / awaiting-approval transitions):
|
|
36
|
+
* always posts regardless of the dedup window.
|
|
37
|
+
*
|
|
38
|
+
* Mutates `dedupState.lastPostAt` and `dedupState.lastSkipReason` on success.
|
|
39
|
+
* Writes a `slackPostFailedEvent` audit entry if `postMessage` returns false.
|
|
40
|
+
*/
|
|
41
|
+
export async function maybePostSlack(slack, slackChannel, db, dedupState, text, currentSkipReason) {
|
|
42
|
+
if (!slack || !slackChannel)
|
|
43
|
+
return;
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
// Always post when transitioning to fire / awaiting-approval.
|
|
46
|
+
// Otherwise dedup: same reason + within window → suppress.
|
|
47
|
+
if (currentSkipReason) {
|
|
48
|
+
const sameReason = currentSkipReason === dedupState.lastSkipReason;
|
|
49
|
+
const withinWindow = now - dedupState.lastPostAt < SLACK_DEDUP_WINDOW_MS;
|
|
50
|
+
if (sameReason && withinWindow)
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const ok = await slack.postMessage(slackChannel, text).catch(() => false);
|
|
54
|
+
if (!ok) {
|
|
55
|
+
void logAuditEventUnchecked(db, slackPostFailedEvent({ channel: slackChannel, reason: "post_returned_false" }));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
dedupState.lastPostAt = now;
|
|
59
|
+
dedupState.lastSkipReason = currentSkipReason;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Persist a release decision row to the `release_decisions` table.
|
|
63
|
+
*
|
|
64
|
+
* @param db - Database client.
|
|
65
|
+
* @param repoUrl - Repository URL (used as the partition key).
|
|
66
|
+
* @param branch - Branch name being managed.
|
|
67
|
+
* @param row - Decision fields. `attemptCount` defaults to 0 when omitted.
|
|
68
|
+
*/
|
|
69
|
+
export async function persistDecision(db, repoUrl, branch, row) {
|
|
70
|
+
await db.insert(releaseDecisions).values({
|
|
71
|
+
id: row.id,
|
|
72
|
+
repoUrl,
|
|
73
|
+
branch,
|
|
74
|
+
decidedAt: new Date(),
|
|
75
|
+
decision: row.decision,
|
|
76
|
+
reason: row.reason,
|
|
77
|
+
triggerStateJson: row.triggerStateJson,
|
|
78
|
+
proposedVersion: row.proposedVersion,
|
|
79
|
+
firedTag: row.firedTag,
|
|
80
|
+
firedSha: row.firedSha,
|
|
81
|
+
attemptCount: row.attemptCount ?? 0,
|
|
82
|
+
qaRunId: row.qaRunId,
|
|
83
|
+
qaRunSha: row.qaRunSha,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Mark the most-recent un-consumed approval row for `(repoUrl, branch)` as
|
|
88
|
+
* consumed by `decisionId`.
|
|
89
|
+
*
|
|
90
|
+
* No-op when no fresh approval exists (e.g. approval was already consumed by a
|
|
91
|
+
* concurrent tick or the approval row was deleted manually).
|
|
92
|
+
*/
|
|
93
|
+
export async function consumeApprovalRow(db, repoUrl, branch, decisionId) {
|
|
94
|
+
const fresh = await db
|
|
95
|
+
.select({ id: releaseApprovals.id })
|
|
96
|
+
.from(releaseApprovals)
|
|
97
|
+
.where(and(eq(releaseApprovals.repoUrl, repoUrl), eq(releaseApprovals.branch, branch), isNull(releaseApprovals.consumedAt)))
|
|
98
|
+
.orderBy(desc(releaseApprovals.approvedAt))
|
|
99
|
+
.limit(1);
|
|
100
|
+
if (fresh?.[0]?.id) {
|
|
101
|
+
await db
|
|
102
|
+
.update(releaseApprovals)
|
|
103
|
+
.set({ consumedAt: new Date(), consumedByDecisionId: decisionId })
|
|
104
|
+
.where(eq(releaseApprovals.id, fresh[0].id));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Query the highest `attemptCount` stored for a given `(repoUrl, branch, reason)` triple.
|
|
109
|
+
*
|
|
110
|
+
* Uses `MAX(attemptCount)` rather than `ORDER BY + LIMIT 1` to be stable when
|
|
111
|
+
* multiple rows share the same `decidedAt` timestamp (e.g. rapid consecutive ticks).
|
|
112
|
+
*
|
|
113
|
+
* @param db - Database client.
|
|
114
|
+
* @param repoUrl - Repository URL.
|
|
115
|
+
* @param branch - Branch name.
|
|
116
|
+
* @param reason - The `reason` column value to filter on (e.g. `"qa_needs_trigger"`).
|
|
117
|
+
* @param qaRunSha - Optional: when supplied, further filters to rows with this `qaRunSha`.
|
|
118
|
+
* Use when tracking retry attempts for a specific commit SHA.
|
|
119
|
+
* @returns The maximum attempt count found, or `0` when no matching rows exist.
|
|
120
|
+
*/
|
|
121
|
+
export async function getMaxAttemptCountForReason(db, repoUrl, branch, reason, qaRunSha) {
|
|
122
|
+
const rows = await db
|
|
123
|
+
.select({ maxAttempts: max(releaseDecisions.attemptCount) })
|
|
124
|
+
.from(releaseDecisions)
|
|
125
|
+
.where(qaRunSha !== undefined
|
|
126
|
+
? and(eq(releaseDecisions.repoUrl, repoUrl), eq(releaseDecisions.branch, branch), eq(releaseDecisions.reason, reason), eq(releaseDecisions.qaRunSha, qaRunSha))
|
|
127
|
+
: and(eq(releaseDecisions.repoUrl, repoUrl), eq(releaseDecisions.branch, branch), eq(releaseDecisions.reason, reason)));
|
|
128
|
+
return rows?.[0]?.maxAttempts ?? 0;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* File a QA gap issue via Linear and handle transient filing errors.
|
|
132
|
+
*
|
|
133
|
+
* Wraps `fileGapIssue` with attempt-count tracking. On a `linear_error` response,
|
|
134
|
+
* increments the per-`(repoUrl, branch, "qa_no_workflow")` attempt counter and
|
|
135
|
+
* escalates `finalReason` to `"qa_gap_file_error"` once `MAX_QA_RETRY_ATTEMPTS`
|
|
136
|
+
* consecutive failures have occurred.
|
|
137
|
+
*
|
|
138
|
+
* On success (`"filed"` or `"already_filed"`), returns
|
|
139
|
+
* `{ finalReason: "qa_no_workflow", attemptCount: 0 }` — the attempt counter
|
|
140
|
+
* resets to 0 to signal that no error has been seen for this filing.
|
|
141
|
+
*
|
|
142
|
+
* @param params.db - Database client.
|
|
143
|
+
* @param params.linear - Linear client (required; caller is responsible for the
|
|
144
|
+
* `if (linear)` guard before calling this helper).
|
|
145
|
+
* @param params.repoUrl - Repository URL.
|
|
146
|
+
* @param params.branch - Branch name.
|
|
147
|
+
* @param params.workflowPath - Configured QA workflow file path (e.g. `".github/workflows/smoke.yml"`).
|
|
148
|
+
* @param params.linearTeamId - Linear team ID to file the gap issue into.
|
|
149
|
+
* @returns `{ finalReason, attemptCount }` for use in the decision row.
|
|
150
|
+
*/
|
|
151
|
+
export async function tryFileQaGapIssue(params) {
|
|
152
|
+
const { db, linear, repoUrl, branch, workflowPath, linearTeamId } = params;
|
|
153
|
+
const gapResult = await fileGapIssue({ db, linear, repoUrl, branch, workflowPath, linearTeamId });
|
|
154
|
+
if (gapResult.kind !== "linear_error") {
|
|
155
|
+
// Filed or already-filed — reset attempt counter for this gap-filing loop.
|
|
156
|
+
return { finalReason: "qa_no_workflow", attemptCount: 0 };
|
|
157
|
+
}
|
|
158
|
+
const prevCount = await getMaxAttemptCountForReason(db, repoUrl, branch, "qa_no_workflow");
|
|
159
|
+
const attemptCount = prevCount + 1;
|
|
160
|
+
const finalReason = attemptCount >= MAX_QA_RETRY_ATTEMPTS ? "qa_gap_file_error" : "qa_no_workflow";
|
|
161
|
+
log.error({ err: gapResult.message, repoUrl, branch }, "fileGapIssue failed; will retry");
|
|
162
|
+
return { finalReason, attemptCount };
|
|
163
|
+
}
|
|
164
|
+
//# sourceMappingURL=release-helpers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"release-helpers.js","sourceRoot":"","sources":["../../src/release-manager/release-helpers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,aAAa,CAAC;AAGzD,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACrE,OAAO,EAAE,sBAAsB,EAAE,MAAM,oBAAoB,CAAC;AAC5D,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAE5C,MAAM,GAAG,GAAG,YAAY,CAAC,EAAE,SAAS,EAAE,wBAAwB,EAAE,CAAC,CAAC;AAOlE,2EAA2E;AAC3E,MAAM,qBAAqB,GAAG,EAAE,GAAG,IAAI,GAAG,IAAI,CAAC;AAgB/C;;;GAGG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,CAAC;AAEvC;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,KAA8B,EAC9B,YAAgC,EAChC,EAAS,EACT,UAA2B,EAC3B,IAAY,EACZ,iBAAgC;IAEhC,IAAI,CAAC,KAAK,IAAI,CAAC,YAAY;QAAE,OAAO;IACpC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IACvB,8DAA8D;IAC9D,2DAA2D;IAC3D,IAAI,iBAAiB,EAAE,CAAC;QACtB,MAAM,UAAU,GAAG,iBAAiB,KAAK,UAAU,CAAC,cAAc,CAAC;QACnE,MAAM,YAAY,GAAG,GAAG,GAAG,UAAU,CAAC,UAAU,GAAG,qBAAqB,CAAC;QACzE,IAAI,UAAU,IAAI,YAAY;YAAE,OAAO;IACzC,CAAC;IACD,MAAM,EAAE,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,YAAY,EAAE,IAAI,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,CAAC;IAC1E,IAAI,CAAC,EAAE,EAAE,CAAC;QACR,KAAK,sBAAsB,CAAC,EAAE,EAAE,oBAAoB,CAAC,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,qBAAqB,EAAE,CAAC,CAAC,CAAC;QAChH,OAAO;IACT,CAAC;IACD,UAAU,CAAC,UAAU,GAAG,GAAG,CAAC;IAC5B,UAAU,CAAC,cAAc,GAAG,iBAAiB,CAAC;AAChD,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,EAAS,EACT,OAAe,EACf,MAAc,EACd,GAWC;IAED,MAAO,EAAU,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC,MAAM,CAAC;QAChD,EAAE,EAAE,GAAG,CAAC,EAAE;QACV,OAAO;QACP,MAAM;QACN,SAAS,EAAE,IAAI,IAAI,EAAE;QACrB,QAAQ,EAAE,GAAG,CAAC,QAAQ;QACtB,MAAM,EAAE,GAAG,CAAC,MAAM;QAClB,gBAAgB,EAAE,GAAG,CAAC,gBAAgB;QACtC,eAAe,EAAE,GAAG,CAAC,eAAe;QACpC,QAAQ,EAAE,GAAG,CAAC,QAAQ;QACtB,QAAQ,EAAE,GAAG,CAAC,QAAQ;QACtB,YAAY,EAAE,GAAG,CAAC,YAAY,IAAI,CAAC;QACnC,OAAO,EAAE,GAAG,CAAC,OAAO;QACpB,QAAQ,EAAE,GAAG,CAAC,QAAQ;KACvB,CAAC,CAAC;AACL,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,EAAS,EACT,OAAe,EACf,MAAc,EACd,UAAkB;IAElB,MAAM,KAAK,GAAG,MAAO,EAAU;SAC5B,MAAM,CAAC,EAAE,EAAE,EAAE,gBAAgB,CAAC,EAAE,EAAE,CAAC;SACnC,IAAI,CAAC,gBAAgB,CAAC;SACtB,KAAK,CACJ,GAAG,CACD,EAAE,CAAC,gBAAgB,CAAC,OAAO,EAAE,OAAO,CAAC,EACrC,EAAE,CAAC,gBAAgB,CAAC,MAAM,EAAE,MAAM,CAAC,EACnC,MAAM,CAAC,gBAAgB,CAAC,UAAU,CAAC,CACpC,CACF;SACA,OAAO,CAAC,IAAI,CAAC,gBAAgB,CAAC,UAAU,CAAC,CAAC;SAC1C,KAAK,CAAC,CAAC,CAAC,CAAC;IACZ,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC;QACnB,MAAO,EAAU;aACd,MAAM,CAAC,gBAAgB,CAAC;aACxB,GAAG,CAAC,EAAE,UAAU,EAAE,IAAI,IAAI,EAAE,EAAE,oBAAoB,EAAE,UAAU,EAAE,CAAC;aACjE,KAAK,CAAC,EAAE,CAAC,gBAAgB,CAAC,EAAE,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IACjD,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,KAAK,UAAU,2BAA2B,CAC/C,EAAS,EACT,OAAe,EACf,MAAc,EACd,MAAc,EACd,QAAiB;IAEjB,MAAM,IAAI,GAAG,MAAO,EAAU;SAC3B,MAAM,CAAC,EAAE,WAAW,EAAE,GAAG,CAAC,gBAAgB,CAAC,YAAY,CAAC,EAAE,CAAC;SAC3D,IAAI,CAAC,gBAAgB,CAAC;SACtB,KAAK,CACJ,QAAQ,KAAK,SAAS;QACpB,CAAC,CAAC,GAAG,CACD,EAAE,CAAC,gBAAgB,CAAC,OAAO,EAAE,OAAO,CAAC,EACrC,EAAE,CAAC,gBAAgB,CAAC,MAAM,EAAE,MAAM,CAAC,EACnC,EAAE,CAAC,gBAAgB,CAAC,MAAM,EAAE,MAAM,CAAC,EACnC,EAAE,CAAC,gBAAgB,CAAC,QAAQ,EAAE,QAAQ,CAAC,CACxC;QACH,CAAC,CAAC,GAAG,CACD,EAAE,CAAC,gBAAgB,CAAC,OAAO,EAAE,OAAO,CAAC,EACrC,EAAE,CAAC,gBAAgB,CAAC,MAAM,EAAE,MAAM,CAAC,EACnC,EAAE,CAAC,gBAAgB,CAAC,MAAM,EAAE,MAAM,CAAC,CACpC,CACN,CAAC;IACJ,OAAO,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,WAAW,IAAI,CAAC,CAAC;AACrC,CAAC;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,MAOvC;IACC,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,YAAY,EAAE,GAAG,MAAM,CAAC;IAC3E,MAAM,SAAS,GAAG,MAAM,YAAY,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,YAAY,EAAE,CAAC,CAAC;IAClG,IAAI,SAAS,CAAC,IAAI,KAAK,cAAc,EAAE,CAAC;QACtC,2EAA2E;QAC3E,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAE,YAAY,EAAE,CAAC,EAAE,CAAC;IAC5D,CAAC;IACD,MAAM,SAAS,GAAG,MAAM,2BAA2B,CAAC,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC3F,MAAM,YAAY,GAAG,SAAS,GAAG,CAAC,CAAC;IACnC,MAAM,WAAW,GAAG,YAAY,IAAI,qBAAqB,CAAC,CAAC,CAAC,mBAAmB,CAAC,CAAC,CAAC,gBAAgB,CAAC;IACnG,GAAG,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,SAAS,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,iCAAiC,CAAC,CAAC;IAC1F,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,CAAC;AACvC,CAAC"}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Octokit } from "@octokit/rest";
|
|
2
|
+
import type { LinearClient } from "@linear/sdk";
|
|
3
|
+
import type { AnyDb } from "../db/client.js";
|
|
4
|
+
import type { ReleaseManagerConfig } from "./types.js";
|
|
5
|
+
import type { SlackPoster, SlackDedupState } from "./release-helpers.js";
|
|
6
|
+
/**
|
|
7
|
+
* Mutable per-instance state that persists across tick invocations.
|
|
8
|
+
*
|
|
9
|
+
* The scheduler factory constructs exactly one `TickMutableState` per
|
|
10
|
+
* `createReleaseManagerScheduler` call and passes it through `TickContext` to
|
|
11
|
+
* every `tick()` call. The tick function reads and writes this object in-place.
|
|
12
|
+
*/
|
|
13
|
+
export interface TickMutableState {
|
|
14
|
+
/** Slack dedup counters — tracks reason + timestamp of last successful post. */
|
|
15
|
+
slackDedup: SlackDedupState;
|
|
16
|
+
/**
|
|
17
|
+
* True once the "release-manager unlicensed" warning has been logged,
|
|
18
|
+
* preventing repeated log spam on every unlicensed tick.
|
|
19
|
+
*/
|
|
20
|
+
licenseWarnLogged: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Set of QA workflow run IDs whose completion has already been audited.
|
|
23
|
+
* Prevents duplicate `qa.run_completed` audit events when the same run ID
|
|
24
|
+
* appears across multiple ticks.
|
|
25
|
+
*
|
|
26
|
+
* Bounded to `MAX_AUDITED_RUN_IDS` entries — evicted (cleared) when the
|
|
27
|
+
* threshold is exceeded to prevent unbounded memory growth in long-running
|
|
28
|
+
* deployments.
|
|
29
|
+
*/
|
|
30
|
+
auditedCompletedRunIds: Set<number>;
|
|
31
|
+
/**
|
|
32
|
+
* Epoch-ms timestamp until which the scheduler is paused (via `/release skip`).
|
|
33
|
+
* Zero means not paused.
|
|
34
|
+
*/
|
|
35
|
+
pausedUntilTs: number;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* All dependencies and configuration needed to run a single release-manager tick.
|
|
39
|
+
*
|
|
40
|
+
* The scheduler constructs a `TickContext` once at startup and passes it to every
|
|
41
|
+
* `tick()` invocation rather than relying on closure variables. This makes the
|
|
42
|
+
* state machine independently testable and explicit about its dependencies.
|
|
43
|
+
*
|
|
44
|
+
* @field config - Full release-manager configuration for this repo/branch pair.
|
|
45
|
+
* @field db - Database client (SQLite dev / Postgres prod).
|
|
46
|
+
* @field octokit - GitHub REST API client.
|
|
47
|
+
* @field linear - Linear client for filing QA gap issues. Required when
|
|
48
|
+
* `config.triggers.qaCheck` is configured.
|
|
49
|
+
* @field repoUrl - HTTPS URL of the repository (e.g. "https://github.com/org/repo").
|
|
50
|
+
* @field branch - Branch name being managed (e.g. "main").
|
|
51
|
+
* @field isLicensed - Injectable license check — returns true when
|
|
52
|
+
* "release-manager" feature is licensed.
|
|
53
|
+
* @field slack - Optional Slack client for posting release notifications.
|
|
54
|
+
* @field mutableState - Per-instance state shared across ticks. Updated in-place.
|
|
55
|
+
*/
|
|
56
|
+
export interface TickContext {
|
|
57
|
+
/** Full release-manager configuration for this repo/branch pair. */
|
|
58
|
+
config: ReleaseManagerConfig;
|
|
59
|
+
/** Database client (SQLite or Postgres). */
|
|
60
|
+
db: AnyDb;
|
|
61
|
+
/** GitHub REST API client. */
|
|
62
|
+
octokit: Octokit;
|
|
63
|
+
/**
|
|
64
|
+
* Linear client for filing QA gap issues.
|
|
65
|
+
* Required when `config.triggers.qaCheck` is configured.
|
|
66
|
+
*/
|
|
67
|
+
linear?: LinearClient;
|
|
68
|
+
/** HTTPS URL of the repository (e.g. "https://github.com/org/repo"). */
|
|
69
|
+
repoUrl: string;
|
|
70
|
+
/** Branch name being managed (e.g. "main"). */
|
|
71
|
+
branch: string;
|
|
72
|
+
/** Injectable license check — returns true when "release-manager" feature is licensed. */
|
|
73
|
+
isLicensed: () => boolean;
|
|
74
|
+
/** Optional Slack client for posting release notifications. */
|
|
75
|
+
slack?: SlackPoster;
|
|
76
|
+
/**
|
|
77
|
+
* Mutable per-instance state shared across ticks.
|
|
78
|
+
* Updated in-place by `tick()` to persist dedup counters, pause state, etc.
|
|
79
|
+
*/
|
|
80
|
+
mutableState: TickMutableState;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Execute a single release-manager decision cycle.
|
|
84
|
+
*
|
|
85
|
+
* Orchestrates the full state machine:
|
|
86
|
+
* 1. License check and pause gate.
|
|
87
|
+
* 2. Collect world state (branch HEAD, tags, CI, approvals, QA run).
|
|
88
|
+
* 3. Manual-tag detection (re-baseline).
|
|
89
|
+
* 4. QA workflow check — trigger, poll, or file gap issue as needed.
|
|
90
|
+
* 5. Decide (skip / awaiting-approval / fire).
|
|
91
|
+
* 6. On skip: persist decision, emit audit event, post Slack with dedup.
|
|
92
|
+
* 7. On awaiting-approval: persist decision, post Slack prompt.
|
|
93
|
+
* 8. On fire: create Git tag + GitHub release, persist decision, post Slack.
|
|
94
|
+
*
|
|
95
|
+
* All dependencies arrive through `ctx`; no closure variables are read or written.
|
|
96
|
+
* Mutable inter-tick state is stored in `ctx.mutableState` and updated in-place.
|
|
97
|
+
*
|
|
98
|
+
* @param ctx - All dependencies and mutable state for this tick invocation.
|
|
99
|
+
*/
|
|
100
|
+
export declare function tick(ctx: TickContext): Promise<void>;
|
|
101
|
+
//# sourceMappingURL=release-tick.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"release-tick.d.ts","sourceRoot":"","sources":["../../src/release-manager/release-tick.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAC7C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAc7C,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAWvD,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAazE;;;;;;GAMG;AACH,MAAM,WAAW,gBAAgB;IAC/B,gFAAgF;IAChF,UAAU,EAAE,eAAe,CAAC;IAC5B;;;OAGG;IACH,iBAAiB,EAAE,OAAO,CAAC;IAC3B;;;;;;;;OAQG;IACH,sBAAsB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACpC;;;OAGG;IACH,aAAa,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,WAAW,WAAW;IAC1B,oEAAoE;IACpE,MAAM,EAAE,oBAAoB,CAAC;IAC7B,4CAA4C;IAC5C,EAAE,EAAE,KAAK,CAAC;IACV,8BAA8B;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB;;;OAGG;IACH,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAC;IAChB,+CAA+C;IAC/C,MAAM,EAAE,MAAM,CAAC;IACf,0FAA0F;IAC1F,UAAU,EAAE,MAAM,OAAO,CAAC;IAC1B,+DAA+D;IAC/D,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB;;;OAGG;IACH,YAAY,EAAE,gBAAgB,CAAC;CAChC;AASD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,IAAI,CAAC,GAAG,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CA4W1D"}
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* release-tick.ts
|
|
3
|
+
*
|
|
4
|
+
* Responsibility: the release-manager decision cycle (QA trigger logic, approval
|
|
5
|
+
* gate, version bump, Git tag creation, and cron rescheduling state).
|
|
6
|
+
*
|
|
7
|
+
* The single exported function `tick(ctx)` receives all dependencies and mutable
|
|
8
|
+
* inter-tick state through an explicit `TickContext` struct instead of closing
|
|
9
|
+
* over variables in the scheduler factory. This makes the function independently
|
|
10
|
+
* testable and removes the 8-variable closure from scheduler.ts.
|
|
11
|
+
*
|
|
12
|
+
* Exports:
|
|
13
|
+
* - TickMutableState — per-instance state that persists between ticks
|
|
14
|
+
* - TickContext — all deps + mutable state for one tick invocation
|
|
15
|
+
* - tick — execute a single release-manager decision cycle
|
|
16
|
+
*/
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { createLogger } from "../logger.js";
|
|
19
|
+
import { logAuditEventUnchecked } from "../audit/writer.js";
|
|
20
|
+
import { releaseFiredEvent, releaseSkippedEvent, releaseTagConflictEvent, releasePartialEvent, qaRunCompletedEvent, } from "../audit/events.js";
|
|
21
|
+
import { collectState } from "./state.js";
|
|
22
|
+
import { decide } from "./decide.js";
|
|
23
|
+
import { bumpFromConfigAndCommits } from "./versioning.js";
|
|
24
|
+
import { createTagAndRelease, parseRepoFromUrl } from "./github.js";
|
|
25
|
+
import { triggerWorkflow, pollWorkflowRun, workflowFileExists } from "../qa/github.js";
|
|
26
|
+
import { markGapResolved } from "../qa/gap.js";
|
|
27
|
+
import { maybePostSlack, persistDecision, consumeApprovalRow, getMaxAttemptCountForReason, tryFileQaGapIssue, MAX_QA_RETRY_ATTEMPTS, } from "./release-helpers.js";
|
|
28
|
+
const log = createLogger({ component: "ReleaseManager:scheduler" });
|
|
29
|
+
/**
|
|
30
|
+
* Maximum number of completed QA run IDs to track in the dedup set before
|
|
31
|
+
* evicting stale entries. Once this threshold is reached the set is cleared;
|
|
32
|
+
* a cleared entry may produce a single duplicate `qa.run_completed` audit
|
|
33
|
+
* event, which is acceptable (far better than unbounded memory growth in
|
|
34
|
+
* long-running deployments).
|
|
35
|
+
*/
|
|
36
|
+
const MAX_AUDITED_RUN_IDS = 10_000;
|
|
37
|
+
/** Compute the approval TTL in milliseconds from config, defaulting to 24 hours. */
|
|
38
|
+
function approvalTtlMs(config) {
|
|
39
|
+
const hours = config.triggers.timeSinceLastHours;
|
|
40
|
+
if (hours && hours > 0)
|
|
41
|
+
return hours * 3600 * 1000;
|
|
42
|
+
return 24 * 3600 * 1000;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Execute a single release-manager decision cycle.
|
|
46
|
+
*
|
|
47
|
+
* Orchestrates the full state machine:
|
|
48
|
+
* 1. License check and pause gate.
|
|
49
|
+
* 2. Collect world state (branch HEAD, tags, CI, approvals, QA run).
|
|
50
|
+
* 3. Manual-tag detection (re-baseline).
|
|
51
|
+
* 4. QA workflow check — trigger, poll, or file gap issue as needed.
|
|
52
|
+
* 5. Decide (skip / awaiting-approval / fire).
|
|
53
|
+
* 6. On skip: persist decision, emit audit event, post Slack with dedup.
|
|
54
|
+
* 7. On awaiting-approval: persist decision, post Slack prompt.
|
|
55
|
+
* 8. On fire: create Git tag + GitHub release, persist decision, post Slack.
|
|
56
|
+
*
|
|
57
|
+
* All dependencies arrive through `ctx`; no closure variables are read or written.
|
|
58
|
+
* Mutable inter-tick state is stored in `ctx.mutableState` and updated in-place.
|
|
59
|
+
*
|
|
60
|
+
* @param ctx - All dependencies and mutable state for this tick invocation.
|
|
61
|
+
*/
|
|
62
|
+
export async function tick(ctx) {
|
|
63
|
+
const { config, db, octokit, linear, repoUrl, branch, isLicensed, slack, mutableState } = ctx;
|
|
64
|
+
const slackChannel = config.slackChannel;
|
|
65
|
+
if (!isLicensed()) {
|
|
66
|
+
if (!mutableState.licenseWarnLogged) {
|
|
67
|
+
log.warn({ repoUrl, branch }, "release-manager unlicensed — skipping ticks");
|
|
68
|
+
mutableState.licenseWarnLogged = true;
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (Date.now() < mutableState.pausedUntilTs) {
|
|
73
|
+
log.info({ pausedUntilTs: mutableState.pausedUntilTs }, "scheduler paused (via /release skip) — skipping tick");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
let state;
|
|
77
|
+
try {
|
|
78
|
+
state = await collectState({
|
|
79
|
+
octokit, db, repoUrl, branch, approvalTtlMs: approvalTtlMs(config),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
log.error({ err, repoUrl, branch }, "collectState failed — skipping tick");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const triggerStateJson = JSON.stringify({
|
|
87
|
+
mergedCommitsSinceLastTag: state.mergedCommitsSinceLastTag,
|
|
88
|
+
lastTag: state.lastTag,
|
|
89
|
+
lastTagAt: state.lastTagAt?.toISOString() ?? null,
|
|
90
|
+
ciStatus: state.ciStatus,
|
|
91
|
+
hasFreshApproval: state.hasFreshApproval,
|
|
92
|
+
});
|
|
93
|
+
// 1. Manual-tag detection — re-baseline counters.
|
|
94
|
+
if (state.manualTagDetected) {
|
|
95
|
+
const id = `rd_${randomUUID()}`;
|
|
96
|
+
await persistDecision(db, repoUrl, branch, {
|
|
97
|
+
id,
|
|
98
|
+
decision: "skip",
|
|
99
|
+
reason: "manual_tag_detected",
|
|
100
|
+
triggerStateJson,
|
|
101
|
+
});
|
|
102
|
+
void logAuditEventUnchecked(db, releaseSkippedEvent({ repoUrl, branch, reason: "manual_tag_detected" }));
|
|
103
|
+
log.info({ repoUrl, branch }, "manual tag detected — re-baselining");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// 2. BEC-136: Compute QA state when qaCheck is configured.
|
|
107
|
+
let qaState;
|
|
108
|
+
if (config.triggers.qaCheck) {
|
|
109
|
+
const { owner, repo } = parseRepoFromUrl(repoUrl);
|
|
110
|
+
let wfExists = false;
|
|
111
|
+
try {
|
|
112
|
+
wfExists = await workflowFileExists({
|
|
113
|
+
octokit, owner, repo,
|
|
114
|
+
path: config.triggers.qaCheck.workflow,
|
|
115
|
+
ref: state.headSha,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
log.warn({ err }, "qa workflowFileExists check failed — treating as exists");
|
|
120
|
+
wfExists = true; // fail-open so retries hit the dispatch path
|
|
121
|
+
}
|
|
122
|
+
if (wfExists) {
|
|
123
|
+
// BEC-136: workflow file is present; if there was an open gap issue for this
|
|
124
|
+
// (repo, branch, workflow), mark it resolved so a future gap can be re-filed.
|
|
125
|
+
await markGapResolved({
|
|
126
|
+
db,
|
|
127
|
+
repoUrl,
|
|
128
|
+
branch,
|
|
129
|
+
workflowPath: config.triggers.qaCheck.workflow,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
let runConclusion = null;
|
|
133
|
+
if (state.qaRun && state.qaRun.runSha === state.headSha) {
|
|
134
|
+
try {
|
|
135
|
+
const polled = await pollWorkflowRun({ octokit, owner, repo, runId: state.qaRun.runId });
|
|
136
|
+
if (polled.kind === "completed") {
|
|
137
|
+
runConclusion = polled.conclusion;
|
|
138
|
+
if (!mutableState.auditedCompletedRunIds.has(state.qaRun.runId)) {
|
|
139
|
+
// BEC-196: bounded-memory eviction. The pre-split scheduler held
|
|
140
|
+
// this set unbounded; the split is the right moment to fix it
|
|
141
|
+
// because state is now passed explicitly via TickMutableState.
|
|
142
|
+
// After eviction a single QA run id can be re-audited once
|
|
143
|
+
// (duplicate `qa.run_completed` event) — acceptable in exchange
|
|
144
|
+
// for bounded memory. The 10k cap is high enough that hitting it
|
|
145
|
+
// requires months of continuous QA runs.
|
|
146
|
+
if (mutableState.auditedCompletedRunIds.size >= MAX_AUDITED_RUN_IDS) {
|
|
147
|
+
mutableState.auditedCompletedRunIds.clear();
|
|
148
|
+
}
|
|
149
|
+
mutableState.auditedCompletedRunIds.add(state.qaRun.runId);
|
|
150
|
+
// Emit qa.run_completed audit on first observation of completion.
|
|
151
|
+
void logAuditEventUnchecked(db, qaRunCompletedEvent({
|
|
152
|
+
repoUrl, branch,
|
|
153
|
+
runId: state.qaRun.runId,
|
|
154
|
+
conclusion: polled.conclusion,
|
|
155
|
+
durationMs: polled.durationMs,
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
// When already in the set: runConclusion is still set above; audit skipped.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
log.warn({ err }, "qa pollWorkflowRun failed — treating as still running");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
qaState = { workflowFileExists: wfExists, runConclusion };
|
|
166
|
+
}
|
|
167
|
+
// 3. Decision.
|
|
168
|
+
const result = decide(state, config.triggers, undefined, qaState);
|
|
169
|
+
const proposedVersion = bumpFromConfigAndCommits(state.lastTag, state.commitsSinceLastTag, config.versionBump);
|
|
170
|
+
if (result.kind === "skip") {
|
|
171
|
+
const id = `rd_${randomUUID()}`;
|
|
172
|
+
// Compute attempt count for retry handling on qa_dispatch_error path.
|
|
173
|
+
let attemptCount = 0;
|
|
174
|
+
let qaRunId;
|
|
175
|
+
let qaRunSha;
|
|
176
|
+
let finalReason = result.reason;
|
|
177
|
+
if (result.qaActionNeeded?.reason === "qa_needs_trigger") {
|
|
178
|
+
// Look up the highest attempt count across all qa_needs_trigger rows for this
|
|
179
|
+
// (branch, sha) pair. Using MAX instead of ORDER BY + LIMIT 1 to be stable
|
|
180
|
+
// when multiple rows share the same decidedAt timestamp.
|
|
181
|
+
attemptCount = await getMaxAttemptCountForReason(db, repoUrl, branch, "qa_needs_trigger", state.headSha);
|
|
182
|
+
const { owner, repo } = parseRepoFromUrl(repoUrl);
|
|
183
|
+
const dispatch = await triggerWorkflow({
|
|
184
|
+
octokit, db, owner, repo, repoUrl, branch,
|
|
185
|
+
workflow: config.triggers.qaCheck.workflow,
|
|
186
|
+
ref: state.headSha,
|
|
187
|
+
inputs: config.triggers.qaCheck.workflowInputs,
|
|
188
|
+
});
|
|
189
|
+
if (dispatch.kind === "ok") {
|
|
190
|
+
attemptCount = 0; // reset on successful dispatch
|
|
191
|
+
qaRunId = dispatch.runId;
|
|
192
|
+
qaRunSha = state.headSha;
|
|
193
|
+
}
|
|
194
|
+
else if (dispatch.kind === "dispatch_404") {
|
|
195
|
+
// Workflow disappeared between state cache and dispatch — drop into gap-issue path.
|
|
196
|
+
finalReason = "qa_no_workflow";
|
|
197
|
+
if (linear) {
|
|
198
|
+
const gapResult = await tryFileQaGapIssue({
|
|
199
|
+
db, linear, repoUrl, branch,
|
|
200
|
+
workflowPath: config.triggers.qaCheck.workflow,
|
|
201
|
+
linearTeamId: config.triggers.qaCheck.linearTeamId,
|
|
202
|
+
});
|
|
203
|
+
finalReason = gapResult.finalReason;
|
|
204
|
+
attemptCount = gapResult.attemptCount;
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
log.error({ repoUrl, branch }, "qaCheck requires Linear client but none configured — skipping gap-issue file");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
else if (dispatch.kind === "dispatch_422") {
|
|
211
|
+
finalReason = "qa_dispatch_error";
|
|
212
|
+
attemptCount = 99; // permanent skip — workflow misconfigured, retrying won't help
|
|
213
|
+
}
|
|
214
|
+
else if (dispatch.kind === "dispatch_pending") {
|
|
215
|
+
// GitHub eventual-consistency window. Don't count against retry budget; next tick
|
|
216
|
+
// will re-evaluate and the run should be findable by then.
|
|
217
|
+
// Tag with qaRunSha so the per-SHA retry counter query can find these rows.
|
|
218
|
+
qaRunSha = state.headSha;
|
|
219
|
+
// attemptCount stays as-is; finalReason stays as "qa_needs_trigger" for next tick to retry.
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
// dispatch_error — increment attempt counter.
|
|
223
|
+
// Tag with qaRunSha so the per-SHA retry counter query can find these rows.
|
|
224
|
+
qaRunSha = state.headSha;
|
|
225
|
+
attemptCount += 1;
|
|
226
|
+
if (attemptCount >= MAX_QA_RETRY_ATTEMPTS) {
|
|
227
|
+
finalReason = "qa_dispatch_error";
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
else if (result.qaActionNeeded?.reason === "qa_no_workflow") {
|
|
232
|
+
if (linear) {
|
|
233
|
+
const gapResult = await tryFileQaGapIssue({
|
|
234
|
+
db, linear, repoUrl, branch,
|
|
235
|
+
workflowPath: config.triggers.qaCheck.workflow,
|
|
236
|
+
linearTeamId: config.triggers.qaCheck.linearTeamId,
|
|
237
|
+
});
|
|
238
|
+
finalReason = gapResult.finalReason;
|
|
239
|
+
attemptCount = gapResult.attemptCount;
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
log.error({ repoUrl, branch }, "qaCheck requires Linear client but none configured — skipping gap-issue file");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (result.qaActionNeeded?.reason === "qa_timed_out" && !result.qaActionNeeded.pass && state.qaRun) {
|
|
246
|
+
const elapsedMs = Date.now() - state.qaRun.triggeredAt.getTime();
|
|
247
|
+
void logAuditEventUnchecked(db, qaRunCompletedEvent({
|
|
248
|
+
repoUrl,
|
|
249
|
+
branch,
|
|
250
|
+
runId: result.qaActionNeeded.runId,
|
|
251
|
+
conclusion: "timed_out",
|
|
252
|
+
durationMs: elapsedMs,
|
|
253
|
+
synthetic: true,
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
await persistDecision(db, repoUrl, branch, {
|
|
257
|
+
id,
|
|
258
|
+
decision: "skip",
|
|
259
|
+
reason: finalReason,
|
|
260
|
+
triggerStateJson,
|
|
261
|
+
proposedVersion,
|
|
262
|
+
qaRunId,
|
|
263
|
+
qaRunSha,
|
|
264
|
+
attemptCount,
|
|
265
|
+
});
|
|
266
|
+
void logAuditEventUnchecked(db, releaseSkippedEvent({ repoUrl, branch, reason: finalReason }));
|
|
267
|
+
// BEC-160: emit a stdout line for every skip so operators tailing
|
|
268
|
+
// docker logs can see the scheduler is alive and why it's skipping.
|
|
269
|
+
log.info({ repoUrl, branch, reason: finalReason, mergedCommitsSinceLastTag: state.mergedCommitsSinceLastTag, lastTag: state.lastTag, proposedVersion }, "tick skip");
|
|
270
|
+
// Slack notification with dedup
|
|
271
|
+
await maybePostSlack(slack, slackChannel, db, mutableState.slackDedup, `:double_vertical_bar: Release skipped for *${repoUrl}* (${branch}): ${finalReason}`, finalReason);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (result.kind === "awaiting-approval") {
|
|
275
|
+
const id = `rd_${randomUUID()}`;
|
|
276
|
+
await persistDecision(db, repoUrl, branch, {
|
|
277
|
+
id,
|
|
278
|
+
decision: "awaiting-approval",
|
|
279
|
+
reason: result.reason,
|
|
280
|
+
triggerStateJson,
|
|
281
|
+
proposedVersion,
|
|
282
|
+
});
|
|
283
|
+
void logAuditEventUnchecked(db, releaseSkippedEvent({ repoUrl, branch, reason: "awaiting-approval" }));
|
|
284
|
+
// BEC-160: stdout visibility for the awaiting-approval skip path.
|
|
285
|
+
log.info({ repoUrl, branch, reason: "awaiting-approval", proposedVersion }, "tick skip");
|
|
286
|
+
// Always post on first transition to awaiting-approval (bypass dedup).
|
|
287
|
+
// Reset lastSkipReason so a subsequent regular-skip will re-post.
|
|
288
|
+
mutableState.slackDedup.lastSkipReason = null;
|
|
289
|
+
await maybePostSlack(slack, slackChannel, db, mutableState.slackDedup, `:hourglass_flowing_sand: Release ready for *${repoUrl}* (${branch}): bumping ${proposedVersion} (${state.mergedCommitsSinceLastTag} commits since last tag). Run \`/release approve\` to fire.`, null);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
// Fire — create tag + release.
|
|
293
|
+
const id = `rd_${randomUUID()}`;
|
|
294
|
+
const githubResult = await createTagAndRelease({
|
|
295
|
+
octokit,
|
|
296
|
+
...parseRepoFromUrl(repoUrl),
|
|
297
|
+
tag: proposedVersion,
|
|
298
|
+
sha: state.headSha,
|
|
299
|
+
});
|
|
300
|
+
if (githubResult.kind === "tag_exists") {
|
|
301
|
+
await persistDecision(db, repoUrl, branch, {
|
|
302
|
+
id,
|
|
303
|
+
decision: "skip",
|
|
304
|
+
reason: "tag_exists",
|
|
305
|
+
triggerStateJson,
|
|
306
|
+
proposedVersion,
|
|
307
|
+
});
|
|
308
|
+
void logAuditEventUnchecked(db, releaseTagConflictEvent({ repoUrl, branch, tag: proposedVersion }));
|
|
309
|
+
// BEC-160: stdout visibility for the tag-exists skip path.
|
|
310
|
+
log.info({ repoUrl, branch, reason: "tag_exists", proposedVersion }, "tick skip");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (githubResult.kind === "release_create_failed") {
|
|
314
|
+
// Tag was created; release-creation failed. Write a single skip row with the
|
|
315
|
+
// partial-fire details so an operator can see what happened and clean up the
|
|
316
|
+
// orphaned tag manually. v1 does NOT retry release-creation across ticks
|
|
317
|
+
// (the tag is now committed, so the next tick would hit `tag_exists` and
|
|
318
|
+
// skip again — see plan §"Known v1 simplifications"). Proper retry is a v2
|
|
319
|
+
// feature requiring a tick-start sweep that calls only `createRelease` for
|
|
320
|
+
// matching fire-pending rows.
|
|
321
|
+
await persistDecision(db, repoUrl, branch, {
|
|
322
|
+
id,
|
|
323
|
+
decision: "skip",
|
|
324
|
+
reason: "release_create_failed",
|
|
325
|
+
triggerStateJson,
|
|
326
|
+
proposedVersion,
|
|
327
|
+
firedTag: proposedVersion,
|
|
328
|
+
firedSha: state.headSha,
|
|
329
|
+
});
|
|
330
|
+
void logAuditEventUnchecked(db, releasePartialEvent({ repoUrl, branch, tag: proposedVersion, attemptCount: 1 }));
|
|
331
|
+
log.error({ repoUrl, branch, tag: proposedVersion, msg: githubResult.message }, "release create failed — tag exists in GitHub but release page not created; manual cleanup required");
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (githubResult.kind === "other_error") {
|
|
335
|
+
await persistDecision(db, repoUrl, branch, {
|
|
336
|
+
id,
|
|
337
|
+
decision: "skip",
|
|
338
|
+
reason: "tag_create_error",
|
|
339
|
+
triggerStateJson,
|
|
340
|
+
proposedVersion,
|
|
341
|
+
});
|
|
342
|
+
void logAuditEventUnchecked(db, releaseSkippedEvent({ repoUrl, branch, reason: "tag_create_error" }));
|
|
343
|
+
log.error({ err: githubResult.message, repoUrl, branch }, "createTagAndRelease unknown error — wrote skip row");
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
// ok — fire succeeded.
|
|
347
|
+
await persistDecision(db, repoUrl, branch, {
|
|
348
|
+
id,
|
|
349
|
+
decision: "fire",
|
|
350
|
+
reason: "all triggers passed",
|
|
351
|
+
triggerStateJson,
|
|
352
|
+
proposedVersion,
|
|
353
|
+
firedTag: proposedVersion,
|
|
354
|
+
firedSha: state.headSha,
|
|
355
|
+
});
|
|
356
|
+
if (state.hasFreshApproval) {
|
|
357
|
+
await consumeApprovalRow(db, repoUrl, branch, id);
|
|
358
|
+
}
|
|
359
|
+
void logAuditEventUnchecked(db, releaseFiredEvent({
|
|
360
|
+
repoUrl,
|
|
361
|
+
branch,
|
|
362
|
+
tag: proposedVersion,
|
|
363
|
+
sha: state.headSha,
|
|
364
|
+
mergedPrCount: state.mergedCommitsSinceLastTag,
|
|
365
|
+
}));
|
|
366
|
+
// BEC-160: stdout visibility for the fire path. A successful release is a
|
|
367
|
+
// production event — operators must see it in `docker logs`, not just the
|
|
368
|
+
// audit table. Slack-suppressed deployments would otherwise be silent.
|
|
369
|
+
log.info({ repoUrl, branch, tag: proposedVersion, sha: state.headSha, mergedPrCount: state.mergedCommitsSinceLastTag, releaseUrl: githubResult.releaseUrl }, "tick fire");
|
|
370
|
+
await maybePostSlack(slack, slackChannel, db, mutableState.slackDedup, `:rocket: Released *${proposedVersion}* for ${repoUrl} (${branch}). ${githubResult.releaseUrl}`, null);
|
|
371
|
+
// Reset Slack dedup so the next skip re-posts.
|
|
372
|
+
mutableState.slackDedup.lastSkipReason = null;
|
|
373
|
+
}
|
|
374
|
+
//# sourceMappingURL=release-tick.js.map
|