@codyswann/lisa 4.27.9 → 4.28.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.
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +43 -8
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +5 -4
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-github-build-intake/SKILL.md +21 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-jira-build-intake/SKILL.md +21 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-linear-build-intake/SKILL.md +21 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-repair-intake/SKILL.md +65 -5
- package/plugins/lisa/.codex-plugin/skills/lisa-validate-tracker-mapping/SKILL.md +134 -3
- package/plugins/lisa/rules/eager/config-resolution.md +23 -0
- package/plugins/lisa/rules/reference/config-resolution.md +65 -0
- package/plugins/lisa/scripts/repo-scope-vocabulary-audit.mjs +458 -0
- package/plugins/lisa/skills/lisa-github-build-intake/SKILL.md +21 -2
- package/plugins/lisa/skills/lisa-jira-build-intake/SKILL.md +21 -2
- package/plugins/lisa/skills/lisa-linear-build-intake/SKILL.md +21 -2
- package/plugins/lisa/skills/lisa-repair-intake/SKILL.md +65 -5
- package/plugins/lisa/skills/lisa-validate-tracker-mapping/SKILL.md +135 -4
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/scripts/repo-scope-vocabulary-audit.mjs +458 -0
- package/plugins/lisa-agy/skills/lisa-github-build-intake/SKILL.md +21 -2
- package/plugins/lisa-agy/skills/lisa-jira-build-intake/SKILL.md +21 -2
- package/plugins/lisa-agy/skills/lisa-linear-build-intake/SKILL.md +21 -2
- package/plugins/lisa-agy/skills/lisa-repair-intake/SKILL.md +65 -5
- package/plugins/lisa-agy/skills/lisa-validate-tracker-mapping/SKILL.md +135 -4
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/rules/eager/config-resolution.md +23 -0
- package/plugins/lisa-copilot/rules/reference/config-resolution.md +65 -0
- package/plugins/lisa-copilot/scripts/repo-scope-vocabulary-audit.mjs +458 -0
- package/plugins/lisa-copilot/skills/lisa-github-build-intake/SKILL.md +21 -2
- package/plugins/lisa-copilot/skills/lisa-jira-build-intake/SKILL.md +21 -2
- package/plugins/lisa-copilot/skills/lisa-linear-build-intake/SKILL.md +21 -2
- package/plugins/lisa-copilot/skills/lisa-repair-intake/SKILL.md +65 -5
- package/plugins/lisa-copilot/skills/lisa-validate-tracker-mapping/SKILL.md +135 -4
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/rules/config-resolution-reference.mdc +65 -0
- package/plugins/lisa-cursor/rules/config-resolution.mdc +23 -0
- package/plugins/lisa-cursor/scripts/repo-scope-vocabulary-audit.mjs +458 -0
- package/plugins/lisa-cursor/skills/lisa-github-build-intake/SKILL.md +21 -2
- package/plugins/lisa-cursor/skills/lisa-jira-build-intake/SKILL.md +21 -2
- package/plugins/lisa-cursor/skills/lisa-linear-build-intake/SKILL.md +21 -2
- package/plugins/lisa-cursor/skills/lisa-repair-intake/SKILL.md +65 -5
- package/plugins/lisa-cursor/skills/lisa-validate-tracker-mapping/SKILL.md +135 -4
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/rules/eager/config-resolution.md +23 -0
- package/plugins/src/base/rules/reference/config-resolution.md +65 -0
- package/plugins/src/base/scripts/repo-scope-vocabulary-audit.mjs +458 -0
- package/plugins/src/base/skills/lisa-github-build-intake/SKILL.md +21 -2
- package/plugins/src/base/skills/lisa-jira-build-intake/SKILL.md +21 -2
- package/plugins/src/base/skills/lisa-linear-build-intake/SKILL.md +21 -2
- package/plugins/src/base/skills/lisa-repair-intake/SKILL.md +65 -5
- package/plugins/src/base/skills/lisa-validate-tracker-mapping/SKILL.md +135 -4
- package/scripts/lib/mutation-performance-reporter.mjs +135 -0
- package/scripts/mutation-performance-measure.mjs +1794 -0
- package/scripts/mutation-performance.schema.json +103 -0
- package/typescript/create-only/.github/workflows/review-evidence.yml +18 -10
|
@@ -89,4 +89,27 @@ A non-integration environment bug is fixed, merged, and verified on that
|
|
|
89
89
|
environment branch first, then
|
|
90
90
|
forward cherry-picked down to the integration branch via a linked follow-up.
|
|
91
91
|
|
|
92
|
+
## Env-keyed `done` — promotion completeness
|
|
93
|
+
|
|
94
|
+
A merged PR's base branch names the environment a change **entered**, never the environments it has
|
|
95
|
+
**reached**. Before writing an env-keyed `done`, walk `deploy.order` from its lowest rung and write
|
|
96
|
+
the highest **contiguously reached** rung at or below the resolved env. A rung is reached only when
|
|
97
|
+
the merge commit is an ancestor of its `deploy.branches` branch
|
|
98
|
+
(`git merge-base --is-ancestor <merge-sha> origin/<branch>`, asserted for **every** env branch at or
|
|
99
|
+
below the resolved one, not only the PR's base) **and** that branch's most recent deploy
|
|
100
|
+
**concluded `success`** — read the `conclusion`, never the `status`, because an in-flight deploy has
|
|
101
|
+
a null conclusion and looks identical to a pass. Only a concluded `success` promotes: a null
|
|
102
|
+
conclusion and every other conclusion (`failure`, `cancelled`, `timed_out`, `neutral`, `skipped`,
|
|
103
|
+
`stale`, `action_required`) leave the rung unreached. Where `deploy.order` is absent the ladder is
|
|
104
|
+
the single resolved env; where a branch exposes no deploy surface at all, ancestry alone decides
|
|
105
|
+
that rung.
|
|
106
|
+
|
|
107
|
+
A hotfix straight to `main` that skipped `staging` therefore resolves to the rung below the gap and
|
|
108
|
+
stays open; the terminal value, and provider-native closure with it, is earned only by a
|
|
109
|
+
promotion-complete merge. The recorded reason always carries all three fields —
|
|
110
|
+
`<first unreached env> (<its branch>) — <condition>`, where the condition is `missing ancestry`,
|
|
111
|
+
`deploy unknown: <run URL or "no concluded run">`, or `deploy concluded <conclusion>: <run URL>`. A
|
|
112
|
+
failing run named without its environment and branch is an incomplete reason. An open back-fill PR
|
|
113
|
+
against a skipped environment branch is outstanding delivery, not branch hygiene.
|
|
114
|
+
|
|
92
115
|
Full reference: [reference/config-resolution.md](../reference/config-resolution.md).
|
|
@@ -600,9 +600,73 @@ Skills that transition to `done` MUST resolve the env first:
|
|
|
600
600
|
1. **Explicit caller arg** (`target_env=staging`) — always wins.
|
|
601
601
|
2. **Branch inference** — derive from the PR's base branch via `deploy.branches`. Reverse-lookup: if base branch is `staging`, env is `staging`.
|
|
602
602
|
3. **Failure** — if neither resolves and `done` is a map, fail loudly. Never pick arbitrarily.
|
|
603
|
+
4. **Promotion completeness** — cap the resolved env by the gate below before writing anything. Steps 1–2 resolve a *candidate*; the gate decides what is written.
|
|
603
604
|
|
|
604
605
|
If a project's terminal state is the same regardless of env, set `done` to a string instead of a map (lifecycle skills accept either shape).
|
|
605
606
|
|
|
607
|
+
#### Promotion completeness: every environment at or below the resolved one
|
|
608
|
+
|
|
609
|
+
A merged PR's base branch says which environment the change **entered**. It never says which
|
|
610
|
+
environments the change has **reached**. A hotfix merged straight to `main` enters production by an
|
|
611
|
+
out-of-order route: the reverse-lookup resolves `production`, `production` is the terminal value,
|
|
612
|
+
and the item closes while `staging` has none of the fix. Reaching the terminal environment is not
|
|
613
|
+
the same as being delivered, and a base branch alone cannot tell those apart.
|
|
614
|
+
|
|
615
|
+
So the resolved env is not the env that gets written. Walk `deploy.order` from its **lowest** rung
|
|
616
|
+
and keep rungs while they are **reached**, stopping at the first that is not. A rung is reached
|
|
617
|
+
when both hold for its `deploy.branches` branch:
|
|
618
|
+
|
|
619
|
+
- **Ancestry** — the merge commit is an ancestor of the remote env branch:
|
|
620
|
+
`git fetch origin <branch> && git merge-base --is-ancestor <merge-sha> origin/<branch>`. Assert
|
|
621
|
+
this for **every** env branch at or below the resolved one — never only the PR's base.
|
|
622
|
+
- **Deploy health** — that branch's most recent deploy **concluded `success`**. Read the
|
|
623
|
+
`conclusion`, never the `status`: an in-flight deploy has a null conclusion and is
|
|
624
|
+
indistinguishable from a pass on the status field alone. Only `success` promotes — a null
|
|
625
|
+
conclusion and every other conclusion (`failure`, `cancelled`, `timed_out`, `neutral`, `skipped`,
|
|
626
|
+
`stale`, `action_required`) leave the rung **unreached**. "Did not fail" is not the test: a
|
|
627
|
+
cancelled or skipped run is not evidence the change deployed.
|
|
628
|
+
|
|
629
|
+
The written env is the highest **contiguously reached** rung at or below the resolved one — never a
|
|
630
|
+
higher rung, even when the higher rung is reached and a lower one is not. A merge present in `dev`
|
|
631
|
+
and `main` but absent from `staging` resolves to `dev` (`On Dev`), not `production`: the item stays
|
|
632
|
+
open at an intermediate waypoint, and the promotion gap is what holds it open. When no rung at all
|
|
633
|
+
is reached, write nothing and leave the item in its current role.
|
|
634
|
+
|
|
635
|
+
Refusals are named, never silent, and one schema covers every case. Whenever the gate caps the env
|
|
636
|
+
below the resolved one, the recorded reason MUST carry **all three** fields — none is optional in
|
|
637
|
+
any case:
|
|
638
|
+
|
|
639
|
+
```
|
|
640
|
+
<first unreached env> (<its branch>) — <condition>
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
`<condition>` is exactly one of:
|
|
644
|
+
|
|
645
|
+
- `missing ancestry` — the merge commit is not an ancestor of that branch.
|
|
646
|
+
- `deploy unknown: <run URL, or "no concluded run">` — the branch has a deploy surface, but its most
|
|
647
|
+
recent run has not concluded, or nothing has ever concluded for it.
|
|
648
|
+
- `deploy concluded <conclusion>: <run URL>` — the most recent run concluded as something other than
|
|
649
|
+
`success` (`failure`, `cancelled`, `timed_out`, `neutral`, `skipped`, `stale`, `action_required`).
|
|
650
|
+
|
|
651
|
+
"Merged to `main` and production deployed green" and "not in `staging`" are both facts; a reason that
|
|
652
|
+
carries only the first is the defect. A failing run named without its environment and branch, or an
|
|
653
|
+
environment named without the condition that failed it, is an incomplete reason and not acceptable.
|
|
654
|
+
|
|
655
|
+
Two classifications this gate forbids:
|
|
656
|
+
|
|
657
|
+
- An **open back-fill PR** against a skipped environment branch is *outstanding delivery*, not
|
|
658
|
+
branch hygiene. It is the evidence that a rung was skipped, so it is a reason to hold the item
|
|
659
|
+
open — never a reason to discount the gap and close anyway.
|
|
660
|
+
- An **in-flight deploy** is unknown, not green. When a rung's ancestry holds but its deploy run has
|
|
661
|
+
not concluded, do not write that rung this cycle: leave the item where it is and let a later cycle
|
|
662
|
+
read a concluded run. Only a concluded success promotes. A project that exposes **no** deploy
|
|
663
|
+
surface for a branch is a different case — there, ancestry alone decides the rung; the absence of
|
|
664
|
+
a deploy system is not an unconcluded deploy.
|
|
665
|
+
|
|
666
|
+
Where `deploy.order` is absent the ladder is the single resolved env, and the gate is the ancestry
|
|
667
|
+
and deploy-health check on that one rung. Multi-branch projects MUST set `deploy.order` (see "Env
|
|
668
|
+
order" below), so an absent order can never silently skip the ladder.
|
|
669
|
+
|
|
606
670
|
### Env → base branch (forward: the build base and PR base)
|
|
607
671
|
|
|
608
672
|
`deploy.branches` is also read in the **forward** direction by the build flow (`lisa-implement`): the environment a work item targets determines the branch the work is built on and the branch the PR opens against.
|
|
@@ -634,6 +698,7 @@ The true terminal `done` value is also the only value that triggers provider-nat
|
|
|
634
698
|
- If `done` is a string, that value is terminal.
|
|
635
699
|
- If `done` is an env-keyed map, the production / final environment's value is terminal. The conventional key is `production`; project-specific final env names must be explicit in deploy config or the lifecycle skill must fail rather than guessing.
|
|
636
700
|
- Intermediate env values (`dev`, `staging`, or configured equivalents) are deployment waypoints. Applying them must not close / resolve / complete the native tracker item.
|
|
701
|
+
- The terminal value is earned only by a **promotion-complete** merge: every env branch at or below the terminal one carries the merge commit, and none of their most recent concluded deploys failed. A merge that reached the terminal environment out of order has not earned the terminal value — see "Promotion completeness" above.
|
|
637
702
|
|
|
638
703
|
### Env order (sync-down chain)
|
|
639
704
|
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Repo-scope vocabulary audit for `/lisa:validate-tracker-mapping`.
|
|
4
|
+
*
|
|
5
|
+
* ## The gap this closes
|
|
6
|
+
*
|
|
7
|
+
* `/lisa:validate-tracker-mapping` audits LIFECYCLE roles: every pair it
|
|
8
|
+
* compares comes from a `(role, configured-name)` mapping declared in
|
|
9
|
+
* `.lisa.config.json`. The SCOPING vocabulary is declared in no config key, so
|
|
10
|
+
* the audit has nothing to compare it against.
|
|
11
|
+
*
|
|
12
|
+
* `repo:` is the scoping family where that blind spot costs the most, because
|
|
13
|
+
* a wrong answer there is not a matter of taste: it decides which work items a
|
|
14
|
+
* repo's build queue can see. When the repo vocabulary drifts, a scan
|
|
15
|
+
* filtering on the canonical spelling returns FEWER items, not an error, and
|
|
16
|
+
* an empty result is indistinguishable from "nothing to do". Work sits
|
|
17
|
+
* unrouted and nobody is told.
|
|
18
|
+
*
|
|
19
|
+
* The OPEN scoping families — `type:`, `priority:`, `points:`, `component:` —
|
|
20
|
+
* are a separate problem with a separate answer, tracked upstream on #3420.
|
|
21
|
+
* This module deliberately does not touch them, for the reason below.
|
|
22
|
+
*
|
|
23
|
+
* ## Why this family asserts membership when `component:` may not
|
|
24
|
+
*
|
|
25
|
+
* The ruling on #3420 — Lisa asserts no authority over project label
|
|
26
|
+
* vocabularies — is about vocabularies that are OPEN and PROJECT-SPECIFIC. An
|
|
27
|
+
* open vocabulary has no wrong member, only inconsistent ones, and declaring
|
|
28
|
+
* one in config would demand per-project curation that becomes its own drift
|
|
29
|
+
* surface. None of that describes `repo:`:
|
|
30
|
+
*
|
|
31
|
+
* - `config-resolution` already declares `repo:<name>` canonical and uniform
|
|
32
|
+
* across trackers, so the canonical FORM is settled upstream, not per project.
|
|
33
|
+
* - The MEMBERS are derived, never curated: the caller resolves them from the
|
|
34
|
+
* repo-identity ladder (config `repo` → `github.repo` → git remote basename)
|
|
35
|
+
* and, in batch mode, from the sibling projects sharing one tracker. This
|
|
36
|
+
* module takes them as input and adds no config key.
|
|
37
|
+
*
|
|
38
|
+
* So there is nothing here for a human to hand-maintain, and no new place for
|
|
39
|
+
* the declaration itself to drift.
|
|
40
|
+
*
|
|
41
|
+
* ## Why these findings are drift and not smells
|
|
42
|
+
*
|
|
43
|
+
* The open families need a heuristic — is `component:plugin` a typo of
|
|
44
|
+
* `component:plugins`? — and a heuristic is sometimes wrong, which is why
|
|
45
|
+
* #3420 rules that findings about them must stay advisory and must never
|
|
46
|
+
* become a gate. That constraint exists to stop a GUESS becoming a gate, and
|
|
47
|
+
* it is not weakened here, because nothing here is a guess. `assertRepoScope`
|
|
48
|
+
* (`lisa-work-item.mjs`, #1957) accepts THREE spellings as valid repo scope:
|
|
49
|
+
*
|
|
50
|
+
* repo:<name> canonical
|
|
51
|
+
* <name> bare label — Sentry-provenance items arrive carrying only this
|
|
52
|
+
* <name> Jira component equal to the bare name
|
|
53
|
+
*
|
|
54
|
+
* The bare branch is deliberate and load-bearing; removing it would break that
|
|
55
|
+
* ingestion path. But every build-intake scanner FILTERS on `repo:<name>`. So
|
|
56
|
+
* an item carrying only the bare spelling passes validation while being
|
|
57
|
+
* invisible to every scan that looks for it. That is not a smell about which
|
|
58
|
+
* reasonable people differ — it is a checkable disagreement between what
|
|
59
|
+
* validation accepts and what filtering finds, and it is the exact shape of the
|
|
60
|
+
* under-read this audit exists to make loud.
|
|
61
|
+
*
|
|
62
|
+
* ## Why nothing here is ever auto-repaired
|
|
63
|
+
*
|
|
64
|
+
* Every finding is `autoRepairable: false`, including under `repair=true`.
|
|
65
|
+
* `repair=true` may rewrite the CONFIG, never the tracker, and every fix
|
|
66
|
+
* available here is a tracker mutation: stamping the canonical label on an
|
|
67
|
+
* item, renaming a malformed marker, or deciding whether an undeclared scope
|
|
68
|
+
* names a real repo or a typo. A human decides; this module reports.
|
|
69
|
+
*
|
|
70
|
+
* @module plugins/src/base/scripts/repo-scope-vocabulary-audit
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
import { realpathSync } from "node:fs";
|
|
74
|
+
import { fileURLToPath } from "node:url";
|
|
75
|
+
|
|
76
|
+
/** The family this module audits. Canonical marker form is `repo:<name>`. */
|
|
77
|
+
export const REPO_SCOPE_FAMILY = "repo";
|
|
78
|
+
|
|
79
|
+
/** The canonical separator between the family and the repo name. */
|
|
80
|
+
export const CANONICAL_SEPARATOR = ":";
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Separators seen standing in for the canonical `:` on a repo marker.
|
|
84
|
+
*
|
|
85
|
+
* A label like `repo-frontend` reads to a human as a repo marker and is not
|
|
86
|
+
* one: no scanner filters on it, and `assertRepoScope` does not accept it
|
|
87
|
+
* either, so it scopes an item to nothing at all. Observed on a live tracker
|
|
88
|
+
* alongside the canonical form, carrying zero items — a vocabulary that had
|
|
89
|
+
* split without anything noticing.
|
|
90
|
+
*/
|
|
91
|
+
export const MALFORMED_SEPARATORS = Object.freeze(["-", "_", "/", "."]);
|
|
92
|
+
|
|
93
|
+
/** Every finding this module can emit. All of them are real drift. */
|
|
94
|
+
export const REPO_SCOPE_FINDING_KINDS = Object.freeze([
|
|
95
|
+
"unstamped-alias",
|
|
96
|
+
"malformed-marker",
|
|
97
|
+
"undeclared-scope",
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @typedef {{
|
|
102
|
+
* readonly ref?: string
|
|
103
|
+
* readonly labels?: readonly (string | { readonly name?: string })[]
|
|
104
|
+
* readonly components?: readonly (string | { readonly name?: string })[]
|
|
105
|
+
* }} WorkItem
|
|
106
|
+
*
|
|
107
|
+
* @typedef {{
|
|
108
|
+
* readonly family: "repo"
|
|
109
|
+
* readonly kind: string
|
|
110
|
+
* readonly label: string
|
|
111
|
+
* readonly repo?: string
|
|
112
|
+
* readonly canonical?: string
|
|
113
|
+
* readonly items: readonly string[]
|
|
114
|
+
* readonly severity: "drift"
|
|
115
|
+
* readonly autoRepairable: false
|
|
116
|
+
* readonly detail: string
|
|
117
|
+
* }} RepoScopeFinding
|
|
118
|
+
*/
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Normalize a label/component list to lowercase names, as `assertRepoScope`
|
|
122
|
+
* does. Its matching is case-insensitive, so a bare `Infrastructure` label
|
|
123
|
+
* scopes an item to `infrastructure` — and is just as invisible to a
|
|
124
|
+
* `repo:infrastructure` filter as a lowercase one would be.
|
|
125
|
+
*
|
|
126
|
+
* @param {readonly (string | { readonly name?: string })[] | undefined} value
|
|
127
|
+
* @returns {string[]} lowercased names, non-strings dropped
|
|
128
|
+
*/
|
|
129
|
+
export function namesFrom(value) {
|
|
130
|
+
if (!Array.isArray(value)) return [];
|
|
131
|
+
return value
|
|
132
|
+
.map(item => (typeof item === "string" ? item : item?.name))
|
|
133
|
+
.filter(name => typeof name === "string")
|
|
134
|
+
.map(name => name.trim().toLowerCase())
|
|
135
|
+
.filter(name => name.length > 0);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The canonical repo marker for a repo short name.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} repo
|
|
142
|
+
* @returns {string} e.g. `repo:frontend`
|
|
143
|
+
*/
|
|
144
|
+
export function canonicalRepoLabel(repo) {
|
|
145
|
+
return `${REPO_SCOPE_FAMILY}${CANONICAL_SEPARATOR}${String(repo).trim().toLowerCase()}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Split a label that is shaped like a repo marker into its separator and value.
|
|
150
|
+
*
|
|
151
|
+
* Returns `null` for anything that does not begin `repo<separator>` — a bare
|
|
152
|
+
* repo name is not a marker (it is an alias, handled per item, because whether
|
|
153
|
+
* it is drift depends on what else that item carries), and `component:frontend`
|
|
154
|
+
* belongs to another family entirely.
|
|
155
|
+
*
|
|
156
|
+
* @param {unknown} name
|
|
157
|
+
* @returns {{ separator: string, value: string } | null}
|
|
158
|
+
*/
|
|
159
|
+
export function parseRepoMarker(name) {
|
|
160
|
+
if (typeof name !== "string") return null;
|
|
161
|
+
const trimmed = name.trim().toLowerCase();
|
|
162
|
+
if (!trimmed.startsWith(REPO_SCOPE_FAMILY)) return null;
|
|
163
|
+
|
|
164
|
+
const separator = trimmed.slice(
|
|
165
|
+
REPO_SCOPE_FAMILY.length,
|
|
166
|
+
REPO_SCOPE_FAMILY.length + 1
|
|
167
|
+
);
|
|
168
|
+
if (
|
|
169
|
+
separator !== CANONICAL_SEPARATOR &&
|
|
170
|
+
!MALFORMED_SEPARATORS.includes(separator)
|
|
171
|
+
) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const value = trimmed.slice(REPO_SCOPE_FAMILY.length + 1).trim();
|
|
176
|
+
if (value.length === 0) return null;
|
|
177
|
+
return { separator, value };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Audit a tracker's repo-scoping vocabulary.
|
|
182
|
+
*
|
|
183
|
+
* Total and read-only: mutates nothing, reaches nothing over the network, and
|
|
184
|
+
* returns every finding rather than the first.
|
|
185
|
+
*
|
|
186
|
+
* @param {{
|
|
187
|
+
* readonly knownRepos?: readonly string[]
|
|
188
|
+
* readonly items?: readonly WorkItem[]
|
|
189
|
+
* readonly labels?: readonly (string | { readonly name?: string })[]
|
|
190
|
+
* }} input
|
|
191
|
+
* `knownRepos` is the derived repo vocabulary — the repo-identity ladder for
|
|
192
|
+
* a single project, or the sibling set in batch mode. `items` are the live
|
|
193
|
+
* work items with their labels (and Jira components). `labels` is the
|
|
194
|
+
* tracker's declared label set, which catches a malformed marker carrying
|
|
195
|
+
* zero items.
|
|
196
|
+
* @returns {{
|
|
197
|
+
* readonly verdict: "VALID" | "DRIFTED" | "UNRESOLVABLE"
|
|
198
|
+
* readonly findings: readonly RepoScopeFinding[]
|
|
199
|
+
* readonly knownAliases: readonly { readonly label: string, readonly canonical: string, readonly items: readonly string[] }[]
|
|
200
|
+
* readonly vocabulary: readonly string[]
|
|
201
|
+
* }}
|
|
202
|
+
*/
|
|
203
|
+
export function auditRepoScopeVocabulary(input = {}) {
|
|
204
|
+
const vocabulary = [
|
|
205
|
+
...new Set(
|
|
206
|
+
(Array.isArray(input.knownRepos) ? input.knownRepos : [])
|
|
207
|
+
.filter(repo => typeof repo === "string")
|
|
208
|
+
.map(repo => repo.trim().toLowerCase())
|
|
209
|
+
.filter(repo => repo.length > 0)
|
|
210
|
+
),
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
// An empty vocabulary cannot be audited, and reporting VALID for it would
|
|
214
|
+
// reproduce the very failure this module exists to catch: a check that
|
|
215
|
+
// returns "nothing wrong" when it in fact looked at nothing.
|
|
216
|
+
if (vocabulary.length === 0) {
|
|
217
|
+
return {
|
|
218
|
+
verdict: /** @type {const} */ ("UNRESOLVABLE"),
|
|
219
|
+
findings: [],
|
|
220
|
+
knownAliases: [],
|
|
221
|
+
vocabulary,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const items = Array.isArray(input.items) ? input.items : [];
|
|
226
|
+
const { aliasFindings, knownAliases } = aliasUsage(items, vocabulary);
|
|
227
|
+
|
|
228
|
+
const findings = [
|
|
229
|
+
...aliasFindings,
|
|
230
|
+
...markerFindings(declaredNames(input.labels, items), vocabulary),
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
verdict: /** @type {const} */ (findings.length === 0 ? "VALID" : "DRIFTED"),
|
|
235
|
+
findings,
|
|
236
|
+
knownAliases,
|
|
237
|
+
vocabulary,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Every label name in play: the tracker's declared set unioned with the labels
|
|
243
|
+
* observed on items.
|
|
244
|
+
*
|
|
245
|
+
* The declared set is what catches a malformed marker nobody has applied yet —
|
|
246
|
+
* a label sitting at zero usage beside the canonical form is a vocabulary that
|
|
247
|
+
* has already split, and the next item to receive it disappears from the queue.
|
|
248
|
+
*
|
|
249
|
+
* @param {readonly (string | { readonly name?: string })[] | undefined} labels
|
|
250
|
+
* @param {readonly WorkItem[]} items
|
|
251
|
+
* @returns {string[]}
|
|
252
|
+
*/
|
|
253
|
+
function declaredNames(labels, items) {
|
|
254
|
+
return [
|
|
255
|
+
...new Set([
|
|
256
|
+
...namesFrom(labels),
|
|
257
|
+
...items.flatMap(item => namesFrom(item?.labels)),
|
|
258
|
+
]),
|
|
259
|
+
];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Per item, per known repo: does what `assertRepoScope` accepts agree with what
|
|
264
|
+
* a canonical filter would find?
|
|
265
|
+
*
|
|
266
|
+
* An item carrying an accepted alias — the bare repo name as a label, or as a
|
|
267
|
+
* Jira component — WITHOUT the canonical `repo:<name>` beside it passes
|
|
268
|
+
* validation and is invisible to every scan. Carrying both is a known alias
|
|
269
|
+
* and harmless: the item is still found. That difference is the whole check,
|
|
270
|
+
* and it is why an alias cannot be judged from the label set alone.
|
|
271
|
+
*
|
|
272
|
+
* @param {readonly WorkItem[]} items
|
|
273
|
+
* @param {readonly string[]} vocabulary
|
|
274
|
+
* @returns {{ aliasFindings: RepoScopeFinding[], knownAliases: { label: string, canonical: string, items: string[] }[] }}
|
|
275
|
+
*/
|
|
276
|
+
function aliasUsage(items, vocabulary) {
|
|
277
|
+
/** @type {Map<string, { repo: string, items: string[] }>} */
|
|
278
|
+
const unstamped = new Map();
|
|
279
|
+
/** @type {Map<string, { repo: string, items: string[] }>} */
|
|
280
|
+
const stamped = new Map();
|
|
281
|
+
|
|
282
|
+
items.forEach((item, index) => {
|
|
283
|
+
const ref = itemRef(item, index);
|
|
284
|
+
const labelNames = namesFrom(item?.labels);
|
|
285
|
+
const componentNames = namesFrom(item?.components);
|
|
286
|
+
|
|
287
|
+
for (const repo of vocabulary) {
|
|
288
|
+
const aliases = [
|
|
289
|
+
labelNames.includes(repo) ? repo : undefined,
|
|
290
|
+
componentNames.includes(repo) ? `component:${repo}` : undefined,
|
|
291
|
+
].filter(alias => alias !== undefined);
|
|
292
|
+
if (aliases.length === 0) continue;
|
|
293
|
+
|
|
294
|
+
const bucket = labelNames.includes(canonicalRepoLabel(repo))
|
|
295
|
+
? stamped
|
|
296
|
+
: unstamped;
|
|
297
|
+
for (const alias of aliases) {
|
|
298
|
+
if (!bucket.has(alias)) bucket.set(alias, { repo, items: [] });
|
|
299
|
+
bucket.get(alias).items.push(ref);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
aliasFindings: [...unstamped].map(([label, entry]) => ({
|
|
306
|
+
family: /** @type {const} */ (REPO_SCOPE_FAMILY),
|
|
307
|
+
kind: "unstamped-alias",
|
|
308
|
+
label,
|
|
309
|
+
repo: entry.repo,
|
|
310
|
+
canonical: canonicalRepoLabel(entry.repo),
|
|
311
|
+
items: entry.items,
|
|
312
|
+
severity: /** @type {const} */ ("drift"),
|
|
313
|
+
autoRepairable: /** @type {const} */ (false),
|
|
314
|
+
detail:
|
|
315
|
+
`"${label}" is an accepted alias for repository "${entry.repo}", so ` +
|
|
316
|
+
`${entry.items.length} work item(s) carrying it pass repo-scope ` +
|
|
317
|
+
`validation — but they do not carry "${canonicalRepoLabel(entry.repo)}", ` +
|
|
318
|
+
`which is what every build-intake scan filters on. Those items are ` +
|
|
319
|
+
`invisible to that scan, and the scan reports no error. Stamping the ` +
|
|
320
|
+
`canonical label alongside the alias is a tracker change a human makes.`,
|
|
321
|
+
})),
|
|
322
|
+
knownAliases: [...stamped].map(([label, entry]) => ({
|
|
323
|
+
label,
|
|
324
|
+
canonical: canonicalRepoLabel(entry.repo),
|
|
325
|
+
items: entry.items,
|
|
326
|
+
})),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Label-set findings: markers that look canonical and are not, and canonical
|
|
332
|
+
* markers naming a repo outside the derived vocabulary.
|
|
333
|
+
*
|
|
334
|
+
* @param {readonly string[]} names
|
|
335
|
+
* @param {readonly string[]} vocabulary
|
|
336
|
+
* @returns {RepoScopeFinding[]}
|
|
337
|
+
*/
|
|
338
|
+
function markerFindings(names, vocabulary) {
|
|
339
|
+
return names.flatMap(name => {
|
|
340
|
+
const marker = parseRepoMarker(name);
|
|
341
|
+
if (!marker) return [];
|
|
342
|
+
|
|
343
|
+
if (marker.separator !== CANONICAL_SEPARATOR) {
|
|
344
|
+
// Only a malformed marker naming a KNOWN repo is reportable. `repo-x` for
|
|
345
|
+
// an unknown `x` is just a label whose name happens to start with "repo";
|
|
346
|
+
// calling it drift would report every unrelated label into the noise.
|
|
347
|
+
if (!vocabulary.includes(marker.value)) return [];
|
|
348
|
+
return [
|
|
349
|
+
{
|
|
350
|
+
family: /** @type {const} */ (REPO_SCOPE_FAMILY),
|
|
351
|
+
kind: "malformed-marker",
|
|
352
|
+
label: name,
|
|
353
|
+
repo: marker.value,
|
|
354
|
+
canonical: canonicalRepoLabel(marker.value),
|
|
355
|
+
items: [],
|
|
356
|
+
severity: /** @type {const} */ ("drift"),
|
|
357
|
+
autoRepairable: /** @type {const} */ (false),
|
|
358
|
+
detail:
|
|
359
|
+
`"${name}" is shaped like a repo marker but uses "${marker.separator}" ` +
|
|
360
|
+
`where the canonical form uses "${CANONICAL_SEPARATOR}". It scopes ` +
|
|
361
|
+
`nothing: no scan filters on it, and repo-scope validation does not ` +
|
|
362
|
+
`accept it either. The canonical form is ` +
|
|
363
|
+
`"${canonicalRepoLabel(marker.value)}".`,
|
|
364
|
+
},
|
|
365
|
+
];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (vocabulary.includes(marker.value)) return [];
|
|
369
|
+
return [
|
|
370
|
+
{
|
|
371
|
+
family: /** @type {const} */ (REPO_SCOPE_FAMILY),
|
|
372
|
+
kind: "undeclared-scope",
|
|
373
|
+
label: name,
|
|
374
|
+
repo: marker.value,
|
|
375
|
+
items: [],
|
|
376
|
+
severity: /** @type {const} */ ("drift"),
|
|
377
|
+
autoRepairable: /** @type {const} */ (false),
|
|
378
|
+
detail:
|
|
379
|
+
`"${name}" scopes work to a repository "${marker.value}" that is not ` +
|
|
380
|
+
`in the derived vocabulary (${vocabulary.join(", ")}). Either it names ` +
|
|
381
|
+
`a repository this tracker serves — in which case the project set is ` +
|
|
382
|
+
`incomplete — or it is a misspelling routing work nowhere. Both are ` +
|
|
383
|
+
`human decisions, so this is never repaired automatically.`,
|
|
384
|
+
},
|
|
385
|
+
];
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* A stable, reportable identifier for a work item.
|
|
391
|
+
*
|
|
392
|
+
* @param {WorkItem} item
|
|
393
|
+
* @param {number} index
|
|
394
|
+
* @returns {string}
|
|
395
|
+
*/
|
|
396
|
+
function itemRef(item, index) {
|
|
397
|
+
const ref = item?.ref;
|
|
398
|
+
return typeof ref === "string" && ref.trim().length > 0
|
|
399
|
+
? ref.trim()
|
|
400
|
+
: `item[${index}]`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* CLI: read `{ knownRepos, items, labels }` on stdin, print the audit.
|
|
405
|
+
*
|
|
406
|
+
* Exits 0 even when the verdict is DRIFTED. The verdict is what the caller
|
|
407
|
+
* reports; the exit status is not a second, quieter channel for the same
|
|
408
|
+
* answer, and a non-zero exit would make this a gate the first time something
|
|
409
|
+
* ran it under `set -e`.
|
|
410
|
+
*
|
|
411
|
+
* @returns {Promise<void>}
|
|
412
|
+
*/
|
|
413
|
+
async function main() {
|
|
414
|
+
const chunks = [];
|
|
415
|
+
for await (const chunk of process.stdin) {
|
|
416
|
+
chunks.push(chunk);
|
|
417
|
+
}
|
|
418
|
+
const payload = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
419
|
+
|
|
420
|
+
process.stdout.write(
|
|
421
|
+
`${JSON.stringify(
|
|
422
|
+
auditRepoScopeVocabulary({
|
|
423
|
+
knownRepos: payload.knownRepos,
|
|
424
|
+
items: payload.items,
|
|
425
|
+
labels: payload.labels,
|
|
426
|
+
}),
|
|
427
|
+
null,
|
|
428
|
+
2
|
|
429
|
+
)}\n`
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* True when `moduleUrl` names the module node was asked to run.
|
|
435
|
+
*
|
|
436
|
+
* Written out rather than imported: this is a plugin payload, which has no
|
|
437
|
+
* `./lib/` to resolve against. Same rule and same reasoning as
|
|
438
|
+
* `scripts/lib/invoked-as-script.mjs` and `lifecycle-label-trust.mjs`.
|
|
439
|
+
*
|
|
440
|
+
* @param {string} moduleUrl - The caller's own `import.meta.url`.
|
|
441
|
+
* @param {string | undefined} [argv1] - Entry path; defaults to `process.argv[1]`.
|
|
442
|
+
* @returns {boolean} Whether the caller should run its CLI body.
|
|
443
|
+
*/
|
|
444
|
+
export function invokedAsScript(moduleUrl, argv1 = process.argv[1]) {
|
|
445
|
+
if (!argv1) return false;
|
|
446
|
+
try {
|
|
447
|
+
return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
|
|
448
|
+
} catch {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (invokedAsScript(import.meta.url)) {
|
|
454
|
+
main().catch(error => {
|
|
455
|
+
process.stderr.write(`${error.message}\n`);
|
|
456
|
+
process.exitCode = 1;
|
|
457
|
+
});
|
|
458
|
+
}
|
|
@@ -56,6 +56,25 @@ For env-keyed `done`, resolve the env first, then look up `done[<env>]`:
|
|
|
56
56
|
2. Otherwise, infer the env from the PR's base branch via `deploy.branches` (reverse lookup).
|
|
57
57
|
3. If `done` is a **string** in config, use it directly regardless of env.
|
|
58
58
|
4. If `done` is a **map** and env cannot be resolved, **fail loudly** — do not pick arbitrarily.
|
|
59
|
+
5. **Promotion completeness caps the result.** The base branch names the environment the change
|
|
60
|
+
*entered*, never the environments it has *reached*. Walk `deploy.order` from its lowest rung and
|
|
61
|
+
write the highest **contiguously reached** rung at or below the resolved env: a rung is reached
|
|
62
|
+
only when the merge commit is an ancestor of its `deploy.branches` branch
|
|
63
|
+
(`git merge-base --is-ancestor <merge-sha> origin/<branch>`, asserted for **every** env branch at
|
|
64
|
+
or below the resolved one — not only the PR's base) **and** that branch's most recent
|
|
65
|
+
deploy **concluded `success`** (read the `conclusion`, never the `status`; only `success`
|
|
66
|
+
promotes — a null conclusion and every other conclusion, `failure` / `cancelled` / `timed_out` /
|
|
67
|
+
`neutral` / `skipped` / `stale` / `action_required`, leave the rung unreached, and an in-flight
|
|
68
|
+
deploy is unknown, not green). Where `deploy.order` is absent the ladder is the single resolved
|
|
69
|
+
env; where a branch exposes no deploy surface at all, ancestry alone decides that rung. A hotfix
|
|
70
|
+
merged straight to the production branch that skipped `staging` therefore writes the rung below
|
|
71
|
+
the gap and stays open. The recorded reason carries all three fields —
|
|
72
|
+
`<first unreached env> (<its branch>) — <condition>`, the condition being `missing ancestry`,
|
|
73
|
+
`deploy unknown: <run URL, or "no concluded run">`, or
|
|
74
|
+
`deploy concluded <conclusion>: <run URL>`; a failing run named without its environment and
|
|
75
|
+
branch is an incomplete reason. An open back-fill PR against a
|
|
76
|
+
skipped environment branch is outstanding delivery, not branch hygiene. See `config-resolution`
|
|
77
|
+
→ "Promotion completeness".
|
|
59
78
|
|
|
60
79
|
```bash
|
|
61
80
|
TARGET_ENV="${target_env:-}"
|
|
@@ -478,10 +497,10 @@ A `done` env state (`status:on-dev`, `status:on-stg`, or the terminal value) ass
|
|
|
478
497
|
If the lifecycle run returned Success:
|
|
479
498
|
|
|
480
499
|
1. **Confirm the PR merged.** Read the live state of the issue's PR — `gh pr view <pr> --json state,mergedAt,mergeStateStatus,url`:
|
|
481
|
-
- **Merged** (`state == MERGED`) → proceed to resolve and apply `$DONE` below. Where the env deploy is observable (a deploy workflow run / deployment status keyed to the merged-into branch via `deploy.branches`),
|
|
500
|
+
- **Merged** (`state == MERGED`) → proceed to resolve and apply `$DONE` below. Where the env deploy is observable (a deploy workflow run / deployment status keyed to the merged-into branch via `deploy.branches`), run the **promotion-completeness gate** (resolution step 5) across **every** env branch at or below the resolved env — not only the merged-into branch. A rung counts only when the merge commit is an ancestor of its branch **and** that branch's most recent deploy concluded `success`; a still-running or otherwise unconcluded deploy is treated like an open PR (leave in `$CLAIMED`), and a rung whose deploy concluded anything but `success` is recorded as an Error. Where a branch exposes no deploy surface at all, ancestry alone decides that rung.
|
|
482
501
|
- **Open / not yet merged** → do **not** transition. The build is sound but the change has reached no environment yet. Record the issue under **"PR open — awaiting merge"** in the summary (with the PR URL and its `mergeStateStatus`), leave it in `$CLAIMED`, and stop. A later `lisa-repair-intake` cycle drives the open PR to merge — re-syncing a `BEHIND` branch so the already-enabled auto-merge can land, or surfacing a real blocker — and, once merged, applies this same env transition. Do **not** comment "Build complete" or close anything.
|
|
483
502
|
- **Closed without merging** → record an Error (the PR was abandoned unmerged); leave the issue in `$CLAIMED`.
|
|
484
|
-
2. Resolve `$DONE` for this issue's PR base branch using the Workflow resolution algorithm above. If env can't be resolved and `done` is env-keyed, record an Error and skip this transition — never guess.
|
|
503
|
+
2. Resolve `$DONE` for this issue's PR base branch using the Workflow resolution algorithm above, **including its promotion-completeness cap** (step 5): the value written is the highest contiguously reached rung at or below the resolved env, never the resolved env by itself. If env can't be resolved and `done` is env-keyed, record an Error and skip this transition — never guess.
|
|
485
504
|
3. Determine whether `$DONE` is the true terminal done value per the `leaf-only-lifecycle` rule's Terminal native closure section:
|
|
486
505
|
- If `github.labels.build.done` is a string, that string is terminal.
|
|
487
506
|
- If `github.labels.build.done` is an object, only the production/final environment value is terminal (default: `status:done`). Intermediate env values such as `status:on-dev` and `status:on-stg` are not terminal and must stay open.
|