looply-cli 0.5.19 → 0.5.20
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/.src-hash +1 -1
- package/dist/branch-delivery-wiring.d.ts +4 -1
- package/dist/branch-delivery-wiring.d.ts.map +1 -1
- package/dist/branch-delivery-wiring.js +14 -3
- package/dist/branch-delivery-wiring.js.map +1 -1
- package/dist/branch-delivery.d.ts +37 -1
- package/dist/branch-delivery.d.ts.map +1 -1
- package/dist/branch-delivery.js +302 -77
- package/dist/branch-delivery.js.map +1 -1
- package/dist/github-api.d.ts +7 -0
- package/dist/github-api.d.ts.map +1 -1
- package/dist/github-api.js +28 -21
- package/dist/github-api.js.map +1 -1
- package/dist/heroku-cli.d.ts +52 -0
- package/dist/heroku-cli.d.ts.map +1 -0
- package/dist/heroku-cli.js +100 -0
- package/dist/heroku-cli.js.map +1 -0
- package/package.json +2 -2
package/dist/branch-delivery.js
CHANGED
|
@@ -15,12 +15,16 @@ exports.describeForeign = describeForeign;
|
|
|
15
15
|
exports.unmergedForeign = unmergedForeign;
|
|
16
16
|
exports.mergeGuard = mergeGuard;
|
|
17
17
|
exports.decideChecks = decideChecks;
|
|
18
|
+
exports.effectiveMode = effectiveMode;
|
|
19
|
+
exports.gatesPassed = gatesPassed;
|
|
18
20
|
exports.advanceDeliveries = advanceDeliveries;
|
|
19
21
|
exports.makeReleaseRunner = makeReleaseRunner;
|
|
20
22
|
const node_child_process_1 = require("node:child_process");
|
|
21
23
|
const node_fs_1 = require("node:fs");
|
|
22
24
|
const node_path_1 = require("node:path");
|
|
23
25
|
const core_1 = require("@looply/core");
|
|
26
|
+
const core_2 = require("@looply/core"); // a value, beside the types above
|
|
27
|
+
const heroku_cli_1 = require("./heroku-cli");
|
|
24
28
|
const delivery_rules_1 = require("./delivery-rules");
|
|
25
29
|
const git_driver_1 = require("./git-driver");
|
|
26
30
|
const integrator_1 = require("./integrator");
|
|
@@ -127,6 +131,26 @@ rebuild = {}) {
|
|
|
127
131
|
const r = await out(['branch', '--no-track', name, sha]).catch(() => null);
|
|
128
132
|
return r !== null && r.code === 0;
|
|
129
133
|
},
|
|
134
|
+
async contains(ancestor, descendant, base) {
|
|
135
|
+
if (!/^[0-9a-f]{7,40}$/.test(ancestor) || !/^[0-9a-f]{7,40}$/.test(descendant))
|
|
136
|
+
return false;
|
|
137
|
+
const commit = async (c) => {
|
|
138
|
+
const r = await out(['rev-parse', '--verify', '--quiet', `${c}^{commit}`]).catch(() => null);
|
|
139
|
+
return r !== null && r.code === 0 && /^[0-9a-f]{40}$/.test(r.stdout.trim()) ? r.stdout.trim() : null;
|
|
140
|
+
};
|
|
141
|
+
let a = await commit(ancestor);
|
|
142
|
+
let d = await commit(descendant);
|
|
143
|
+
const name = (0, delivery_rules_1.shortBranch)(base);
|
|
144
|
+
if ((a === null || d === null) && /^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(name) && !name.includes('..')) {
|
|
145
|
+
// Read-only for the person: it moves origin's remote-tracking copy and nothing else.
|
|
146
|
+
await out(['fetch', '--quiet', '--no-tags', delivery_rules_1.DELIVERY_REMOTE, `refs/heads/${name}:refs/remotes/${delivery_rules_1.DELIVERY_REMOTE}/${name}`]).catch(() => undefined);
|
|
147
|
+
a = a ?? (await commit(ancestor));
|
|
148
|
+
d = d ?? (await commit(descendant));
|
|
149
|
+
}
|
|
150
|
+
if (a === null || d === null)
|
|
151
|
+
return false;
|
|
152
|
+
return a === d || (await driver.isAncestor(a, d).catch(() => false));
|
|
153
|
+
},
|
|
130
154
|
};
|
|
131
155
|
}
|
|
132
156
|
/**
|
|
@@ -352,7 +376,9 @@ function pullBody(input) {
|
|
|
352
376
|
? 'a person reviews and merges.'
|
|
353
377
|
: input.mode === 'merge'
|
|
354
378
|
? 'Looply merges this once the required CI checks are green.'
|
|
355
|
-
:
|
|
379
|
+
: input.mode === 'test'
|
|
380
|
+
? "ship to test: Looply merges this once the required CI checks are green (or, when the repository has no CI, once its own checks above passed), then deploys the test environment only. Other environments are deployed only when a person promotes it."
|
|
381
|
+
: "Looply merges this once the required CI checks are green, then runs the project's declared release step."}${input.modeCapped ? ` (${input.modeCapped}.)` : ''}`,
|
|
356
382
|
];
|
|
357
383
|
if (input.maintenance)
|
|
358
384
|
lines.push('', ...maintenanceSection(input.maintenance));
|
|
@@ -408,6 +434,7 @@ async function deliverOnce(deps, input) {
|
|
|
408
434
|
projectBase: null,
|
|
409
435
|
mode: input.mode,
|
|
410
436
|
...(input.maintenance && !input.maintenance.autoMerge ? { noAutoMerge: true } : {}),
|
|
437
|
+
...(input.checks ? { checks: { ...input.checks } } : {}),
|
|
411
438
|
state: 'not-pushed',
|
|
412
439
|
note: null,
|
|
413
440
|
dependsOn: null,
|
|
@@ -590,13 +617,14 @@ async function deliverOnce(deps, input) {
|
|
|
590
617
|
: foreignText !== null
|
|
591
618
|
? tail(foreignText)
|
|
592
619
|
: null;
|
|
620
|
+
const modeNote = input.modeNote ?? null;
|
|
593
621
|
return save(deps, {
|
|
594
622
|
...entry,
|
|
595
623
|
pullNumber: pull.number,
|
|
596
624
|
pullRequestUrl: pull.url,
|
|
597
625
|
base: pull.base || prBase,
|
|
598
626
|
state,
|
|
599
|
-
note,
|
|
627
|
+
note: modeNote === null ? note : note === null ? modeNote : `${note}; ${modeNote}`.slice(0, 600),
|
|
600
628
|
...(absorbs.length > 0 ? { absorbs } : {}),
|
|
601
629
|
});
|
|
602
630
|
}
|
|
@@ -788,6 +816,21 @@ function decideChecks(required, checks) {
|
|
|
788
816
|
const waiting = checks.filter((c) => c.state === 'pending').map((c) => c.name);
|
|
789
817
|
return waiting.length > 0 ? { kind: 'pending', waiting } : { kind: 'green' };
|
|
790
818
|
}
|
|
819
|
+
/**
|
|
820
|
+
* THE MODE IN FORCE, WITH SHIP TO TEST'S SAFETY APPLIED: `test` whose settings `shipToTestRefusal`
|
|
821
|
+
* refuses is `pr`, and the reason comes with it. Every other mode is as asked.
|
|
822
|
+
*/
|
|
823
|
+
function effectiveMode(asked, settings) {
|
|
824
|
+
if (asked !== 'test')
|
|
825
|
+
return { mode: asked, refused: null };
|
|
826
|
+
const refused = (0, core_2.shipToTestRefusal)({ deploy: settings.deploy, environments: settings.environments.filter((e) => !e.unnamed) });
|
|
827
|
+
return refused === null ? { mode: 'test', refused: null } : { mode: 'pr', refused: `ship to test is off: ${refused}` };
|
|
828
|
+
}
|
|
829
|
+
/** Looply's own gates for the delivered commit all passed — at least one, and none failed or skipped. */
|
|
830
|
+
function gatesPassed(checks) {
|
|
831
|
+
const outcomes = Object.values(checks ?? {});
|
|
832
|
+
return outcomes.length > 0 && outcomes.every((o) => o === 'passed');
|
|
833
|
+
}
|
|
791
834
|
/**
|
|
792
835
|
* States a pass may move on. `pr-open` and `checks-failed` move only on a person's approval.
|
|
793
836
|
* `blocked` is re-examined on every pass: it unblocks by itself once the other goals' work it
|
|
@@ -808,7 +851,10 @@ const APPROVABLE = ['pr-open', 'waiting-checks', 'checks-failed', 'blocked'];
|
|
|
808
851
|
*/
|
|
809
852
|
async function advanceDeliveries(deps) {
|
|
810
853
|
for (const e of deps.store.all()) {
|
|
811
|
-
if (
|
|
854
|
+
if (!ADVANCING.includes(e.state))
|
|
855
|
+
continue;
|
|
856
|
+
// A finished one is looked at again only for a promote it has not acted on yet.
|
|
857
|
+
if (e.done === true && !(MERGED.includes(e.state) && pendingPromotes(deps, e).length > 0))
|
|
812
858
|
continue;
|
|
813
859
|
try {
|
|
814
860
|
await advanceOne(deps, e);
|
|
@@ -821,8 +867,11 @@ async function advanceDeliveries(deps) {
|
|
|
821
867
|
async function advanceOne(deps, e) {
|
|
822
868
|
const now = deps.now();
|
|
823
869
|
const at = new Date(now).toISOString();
|
|
824
|
-
const
|
|
825
|
-
const
|
|
870
|
+
const capped = deps.mode(e.goalId);
|
|
871
|
+
const asked = e.noAutoMerge === true && (capped === 'merge' || capped === 'test' || capped === 'deploy') ? 'pr' : capped;
|
|
872
|
+
// 🔴 SHIP TO TEST'S SAFETY, asked again on every pass: settings that changed since the delivery count.
|
|
873
|
+
const safe = effectiveMode(asked, { deploy: deps.deploy(), environments: deps.environments?.() ?? [] });
|
|
874
|
+
const mode = safe.mode;
|
|
826
875
|
// A person's approval, read from the goal mirror.
|
|
827
876
|
if (!e.approvedAt && APPROVABLE.includes(e.state) && e.pullNumber !== null) {
|
|
828
877
|
const approved = deps.shipRequested(e.goalId);
|
|
@@ -836,10 +885,11 @@ async function advanceOne(deps, e) {
|
|
|
836
885
|
}
|
|
837
886
|
}
|
|
838
887
|
const approved = !!e.approvedAt;
|
|
839
|
-
const wantsMerge = approved || mode === 'merge' || mode === 'deploy';
|
|
888
|
+
const wantsMerge = approved || mode === 'merge' || mode === 'test' || mode === 'deploy';
|
|
840
889
|
const wantsDeploy = approved || mode === 'deploy';
|
|
841
890
|
if (e.state === 'waiting-checks' && !wantsMerge) {
|
|
842
|
-
|
|
891
|
+
const why = safe.refused ?? `delivery is now "${mode}"`;
|
|
892
|
+
await save(deps, { ...e, mode, state: 'pr-open', note: `${why}, so Looply will not merge it; a person merges`, updatedAt: at });
|
|
843
893
|
return;
|
|
844
894
|
}
|
|
845
895
|
const client = e.owner !== null && e.repo !== null ? await deps.github(e.owner, e.repo) : null;
|
|
@@ -938,12 +988,26 @@ async function advanceOne(deps, e) {
|
|
|
938
988
|
await save(deps, { ...e, state: 'checks-failed', note: `not merged: ${verdict.failed.join(', ')} failed`, updatedAt: at });
|
|
939
989
|
return;
|
|
940
990
|
}
|
|
941
|
-
|
|
991
|
+
/**
|
|
992
|
+
* No CI at all: an automatic merge waits a little for CI to show up, then hands it to a person;
|
|
993
|
+
* a person's approval is the check. Under ship to test, Looply's OWN gates for the delivered
|
|
994
|
+
* commit are the check instead — every one of them passed, or it goes to a person as before.
|
|
995
|
+
*/
|
|
996
|
+
let noCiNote = null;
|
|
942
997
|
if (verdict.kind === 'none' && !approved) {
|
|
943
998
|
if (age < exports.NO_CHECKS_GRACE_MS)
|
|
944
999
|
return;
|
|
945
|
-
|
|
946
|
-
|
|
1000
|
+
// The gates ran on the delivered commit: a head that moved, or a `-solo` rebuild, is not what they judged.
|
|
1001
|
+
const sameCommit = (pull.headSha === '' || pull.headSha === e.sha) && !e.rebuiltFrom;
|
|
1002
|
+
const gatesHold = mode === 'test' && gatesPassed(e.checks) && sameCommit;
|
|
1003
|
+
if (!gatesHold) {
|
|
1004
|
+
const why = mode === 'test'
|
|
1005
|
+
? `not merged: no CI checks report on this pull request, and ${sameCommit ? "Looply's own checks for it did not all pass" : "Looply's own checks ran on a different commit than its head"}, so a person merges it`
|
|
1006
|
+
: 'not merged: no CI checks report on this pull request, so a person merges it';
|
|
1007
|
+
await save(deps, { ...e, state: 'pr-open', note: why, updatedAt: at });
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
noCiNote = `merged on Looply's own checks (${Object.keys(e.checks ?? {}).join(', ')} passed); the repository reports no CI`;
|
|
947
1011
|
}
|
|
948
1012
|
if (verdict.kind === 'pending') {
|
|
949
1013
|
if (age > exports.CHECKS_PATIENCE_MS) {
|
|
@@ -957,7 +1021,7 @@ async function advanceOne(deps, e) {
|
|
|
957
1021
|
return;
|
|
958
1022
|
}
|
|
959
1023
|
deps.log(`deliver : merged ${e.pullRequestUrl} into ${pull.base}`);
|
|
960
|
-
e = { ...e, state: 'merged', mergeSha: merged.sha ?? null, note:
|
|
1024
|
+
e = { ...e, state: 'merged', mergeSha: merged.sha ?? null, note: noCiNote, updatedAt: at };
|
|
961
1025
|
await save(deps, e);
|
|
962
1026
|
}
|
|
963
1027
|
}
|
|
@@ -967,6 +1031,8 @@ async function advanceOne(deps, e) {
|
|
|
967
1031
|
/* ────────────────────────────────────────── where the merge got to ── */
|
|
968
1032
|
/** How long a merged commit's deploys are followed for, so one done outside Looply is still seen. */
|
|
969
1033
|
exports.FOLLOW_DEPLOYS_MS = 14 * 24 * 60 * 60_000;
|
|
1034
|
+
/** The states of a delivery whose work is in the base. */
|
|
1035
|
+
const MERGED = ['merged', 'deployed', 'deploy-failed'];
|
|
970
1036
|
/** The environments to deploy, in order; the single `deploy` (or, under `deploy`, a declared release step) when none are named. */
|
|
971
1037
|
function plansFor(deps, mode) {
|
|
972
1038
|
const named = deps.environments?.() ?? [];
|
|
@@ -978,19 +1044,38 @@ function plansFor(deps, mode) {
|
|
|
978
1044
|
return [];
|
|
979
1045
|
return [{ name: d.kind === 'push' ? d.remote : 'default', deploy: d, unnamed: true }];
|
|
980
1046
|
}
|
|
1047
|
+
/**
|
|
1048
|
+
* The commit a merged delivery's deploys are about: the merge commit, else — a pull request a
|
|
1049
|
+
* person merged where GitHub named no merge commit — its head, which the base now contains.
|
|
1050
|
+
*/
|
|
1051
|
+
function subjectOf(e) {
|
|
1052
|
+
return e.mergeSha ?? (/^[0-9a-f]{40}$/.test(e.sha) ? e.sha : null);
|
|
1053
|
+
}
|
|
1054
|
+
/** The promote requests on the goal that this delivery has not acted on yet. */
|
|
1055
|
+
function pendingPromotes(deps, e) {
|
|
1056
|
+
const asked = deps.promoteRequests?.(e.goalId) ?? [];
|
|
1057
|
+
const done = e.promoted ?? {};
|
|
1058
|
+
return asked.filter((r) => done[r.environment.toLowerCase()] === undefined);
|
|
1059
|
+
}
|
|
981
1060
|
/**
|
|
982
1061
|
* 🔴 WHERE A MERGED COMMIT IS, TOLD TRUTHFULLY, PER ENVIRONMENT.
|
|
983
1062
|
*
|
|
984
1063
|
* The first time it is seen merged, every environment gets a row and Looply runs its OWN deploy
|
|
985
|
-
* steps, in order (
|
|
986
|
-
*
|
|
1064
|
+
* steps, in order (under `deploy` or "Approve and ship" every one; under ship to test ONLY the test
|
|
1065
|
+
* environment; a failure stops the ones after it):
|
|
1066
|
+
* - `push` → deployed when the git push exited 0 — to a Heroku app (`herokuApp`), only once the
|
|
1067
|
+
* app's newest release is, or contains, the commit; until then unconfirmed;
|
|
987
1068
|
* - `run` → deployed when the project's declared release step exited 0;
|
|
988
1069
|
* - `merge-deploys` → UNCONFIRMED: the merge should deploy it, but a merge is not a deploy;
|
|
989
1070
|
* - `none` → waiting: nothing here deploys it.
|
|
990
|
-
*
|
|
991
|
-
*
|
|
992
|
-
*
|
|
993
|
-
*
|
|
1071
|
+
* A person's PROMOTE to another environment deploys the commit there, once, on the next pass —
|
|
1072
|
+
* the only way a non-test environment is ever deployed under ship to test.
|
|
1073
|
+
* Then, every pass, an environment that is not settled is looked up: GitHub's Deployments of the
|
|
1074
|
+
* commit (an environment of the same name — any, for the one unnamed environment — `success` is
|
|
1075
|
+
* `deployed`, `failure`/`error` is `failed`, still running is `deploying`); GitHub's newest
|
|
1076
|
+
* successful deployment of that environment when it is of a LATER commit that contains this one;
|
|
1077
|
+
* and a Heroku app's newest release, by the same rule. That is how a Heroku or Netlify deploy, or one
|
|
1078
|
+
* a person ran by hand, is seen. Nothing else ever makes a row `deployed`.
|
|
994
1079
|
*
|
|
995
1080
|
* The delivery is `done` once every environment is deployed or failed, or 14 days after the merge;
|
|
996
1081
|
* a pass that changes nothing records nothing. No environment configured: done at the merge, and
|
|
@@ -1001,18 +1086,34 @@ async function followDeploys(deps, api, e, wantsDeploy, mode) {
|
|
|
1001
1086
|
const at = new Date(nowMs).toISOString();
|
|
1002
1087
|
let x = { ...e, mergedAt: e.mergedAt ?? at };
|
|
1003
1088
|
const plans = plansFor(deps, mode);
|
|
1004
|
-
if (x.environments === undefined) {
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1089
|
+
if (x.environments === undefined && plans.length === 0) {
|
|
1090
|
+
// A promote has nowhere to go: acted on (nothing), so it is not asked about again.
|
|
1091
|
+
const promoted = { ...(x.promoted ?? {}) };
|
|
1092
|
+
for (const r of pendingPromotes(deps, x))
|
|
1093
|
+
promoted[r.environment.toLowerCase()] = r.requestedAt;
|
|
1094
|
+
await save(deps, {
|
|
1095
|
+
...x,
|
|
1096
|
+
done: true,
|
|
1097
|
+
deploy: { kind: 'none', ok: true, detail: 'merged; nothing is configured to deploy' },
|
|
1098
|
+
...(Object.keys(promoted).length > 0 ? { promoted } : {}),
|
|
1099
|
+
updatedAt: at,
|
|
1100
|
+
});
|
|
1101
|
+
return;
|
|
1010
1102
|
}
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1103
|
+
// The environments whose Heroku releases this pass's own deploys already read: not read twice.
|
|
1104
|
+
const asked = new Set();
|
|
1105
|
+
if (x.environments === undefined)
|
|
1106
|
+
x = await deployAll(deps, x, plans, wantsDeploy, mode, at, asked);
|
|
1107
|
+
x = await promoteAll(deps, x, plans, at, asked);
|
|
1108
|
+
const sha = subjectOf(x);
|
|
1109
|
+
const base = x.projectBase ?? x.base ?? '';
|
|
1110
|
+
if (sha !== null) {
|
|
1111
|
+
if (api?.deploymentsFor) {
|
|
1112
|
+
const found = await api.deploymentsFor(sha).catch(() => null);
|
|
1113
|
+
if (found !== null)
|
|
1114
|
+
x = { ...x, environments: (x.environments ?? []).map((row) => fromGitHub(row, plans, found)) };
|
|
1115
|
+
}
|
|
1116
|
+
x = await laterDeploys(deps, api, x, plans, sha, base, at, asked);
|
|
1016
1117
|
}
|
|
1017
1118
|
const rows = x.environments ?? [];
|
|
1018
1119
|
const settled = rows.every((r) => r.state === 'deployed' || r.state === 'failed');
|
|
@@ -1028,67 +1129,40 @@ async function followDeploys(deps, api, e, wantsDeploy, mode) {
|
|
|
1028
1129
|
if (!same)
|
|
1029
1130
|
await save(deps, { ...x, updatedAt: at });
|
|
1030
1131
|
}
|
|
1132
|
+
/** One environment's row as Looply's own step for it left it. */
|
|
1133
|
+
function envRow(p, sha, at, state, detail, when = null) {
|
|
1134
|
+
return {
|
|
1135
|
+
name: p.name,
|
|
1136
|
+
test: p.test === true,
|
|
1137
|
+
state,
|
|
1138
|
+
sha,
|
|
1139
|
+
at: state === 'waiting' ? null : (when ?? at),
|
|
1140
|
+
url: p.url ?? null,
|
|
1141
|
+
detail: detail.slice(0, 600),
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1031
1144
|
/** Run Looply's own deploy step for each environment, in order, and make every row. */
|
|
1032
|
-
async function deployAll(deps, e, plans, wantsDeploy, at) {
|
|
1033
|
-
const
|
|
1034
|
-
const sha = e.mergeSha ?? null;
|
|
1145
|
+
async function deployAll(deps, e, plans, wantsDeploy, mode, at, asked) {
|
|
1146
|
+
const sha = subjectOf(e);
|
|
1035
1147
|
const rows = [];
|
|
1036
1148
|
let stoppedBy = null;
|
|
1037
1149
|
for (const p of plans) {
|
|
1038
|
-
const row = (state, detail) => ({
|
|
1039
|
-
name: p.name,
|
|
1040
|
-
test: p.test === true,
|
|
1041
|
-
state,
|
|
1042
|
-
sha,
|
|
1043
|
-
at: state === 'waiting' ? null : at,
|
|
1044
|
-
url: p.url ?? null,
|
|
1045
|
-
detail: detail.slice(0, 600),
|
|
1046
|
-
});
|
|
1047
1150
|
const d = p.deploy;
|
|
1048
1151
|
if (stoppedBy !== null) {
|
|
1049
|
-
rows.push(
|
|
1050
|
-
continue;
|
|
1051
|
-
}
|
|
1052
|
-
if (d.kind === 'none') {
|
|
1053
|
-
rows.push(row('waiting', `no deploy step is configured for ${p.name}`));
|
|
1152
|
+
rows.push(envRow(p, sha, at, 'waiting', `not deployed: the deploy to ${stoppedBy} failed first`));
|
|
1054
1153
|
continue;
|
|
1055
1154
|
}
|
|
1056
|
-
|
|
1057
|
-
|
|
1155
|
+
// 🔴 SHIP TO TEST deploys the test environment and nothing else — not even on an approval.
|
|
1156
|
+
const mine = d.kind === 'push' || d.kind === 'run';
|
|
1157
|
+
if (mine && mode === 'test' && p.test !== true) {
|
|
1158
|
+
rows.push(envRow(p, sha, at, 'waiting', `ship to test deploys only the test environment; ${p.name} is deployed when a person promotes it`));
|
|
1058
1159
|
continue;
|
|
1059
1160
|
}
|
|
1060
|
-
if (!wantsDeploy) {
|
|
1061
|
-
rows.push(
|
|
1161
|
+
if (mine && mode !== 'test' && !wantsDeploy) {
|
|
1162
|
+
rows.push(envRow(p, sha, at, 'waiting', `Looply deploys only under "deploy" or after "Approve and ship"`));
|
|
1062
1163
|
continue;
|
|
1063
1164
|
}
|
|
1064
|
-
|
|
1065
|
-
if (sha === null) {
|
|
1066
|
-
rows.push(row('failed', 'merged, but GitHub did not say which commit the merge made, so nothing was pushed to deploy'));
|
|
1067
|
-
}
|
|
1068
|
-
else {
|
|
1069
|
-
deps.log(`deliver : deploying ${sha.slice(0, 12)} to ${d.remote} ${d.branch} (${p.name})`);
|
|
1070
|
-
const r = await deps.deployPush(d.remote, d.branch, sha, base);
|
|
1071
|
-
rows.push(r.ok ? row('deployed', `pushed ${sha.slice(0, 12)} to ${d.branch} on ${d.remote}`) : row('failed', `pushing to ${d.remote} ${d.branch} failed: ${r.because}`));
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
|
-
else {
|
|
1075
|
-
const command = deps.release();
|
|
1076
|
-
if (command === null) {
|
|
1077
|
-
rows.push(row('failed', 'merged; not deployed — no release step is declared (deliver.release in .looply/config.yaml)'));
|
|
1078
|
-
}
|
|
1079
|
-
else {
|
|
1080
|
-
deps.log(`deliver : running the declared release step for ${e.goalId} (${p.name})`);
|
|
1081
|
-
const r = await deps.runRelease(command, {
|
|
1082
|
-
LOOPLY_GOAL_ID: e.goalId,
|
|
1083
|
-
LOOPLY_BRANCH: e.branch,
|
|
1084
|
-
LOOPLY_BASE_BRANCH: base,
|
|
1085
|
-
LOOPLY_MERGED_SHA: sha ?? '',
|
|
1086
|
-
LOOPLY_PULL_REQUEST_URL: e.pullRequestUrl ?? '',
|
|
1087
|
-
LOOPLY_ENVIRONMENT: p.name,
|
|
1088
|
-
});
|
|
1089
|
-
rows.push(r.code === 0 ? row('deployed', 'the declared release step finished') : row('failed', `the release step exited ${r.code}: ${r.tail.slice(-300)}`));
|
|
1090
|
-
}
|
|
1091
|
-
}
|
|
1165
|
+
rows.push(await deployOne(deps, e, p, sha, at, asked));
|
|
1092
1166
|
if (rows[rows.length - 1].state === 'failed')
|
|
1093
1167
|
stoppedBy = p.name;
|
|
1094
1168
|
}
|
|
@@ -1097,6 +1171,157 @@ async function deployAll(deps, e, plans, wantsDeploy, at) {
|
|
|
1097
1171
|
const leadPlan = plans[rows.indexOf(lead)];
|
|
1098
1172
|
return { ...e, environments: rows, deploy: { kind: leadPlan.deploy.kind, ok: lead.state !== 'failed', detail: lead.detail ?? '' } };
|
|
1099
1173
|
}
|
|
1174
|
+
/** A person's promotes not acted on yet: each environment is deployed once, and its row replaced. */
|
|
1175
|
+
async function promoteAll(deps, e, plans, at, asked) {
|
|
1176
|
+
const pending = pendingPromotes(deps, e);
|
|
1177
|
+
if (pending.length === 0)
|
|
1178
|
+
return e;
|
|
1179
|
+
const promoted = { ...(e.promoted ?? {}) };
|
|
1180
|
+
const rows = [...(e.environments ?? [])];
|
|
1181
|
+
const sha = subjectOf(e);
|
|
1182
|
+
for (const r of pending) {
|
|
1183
|
+
const key = r.environment.toLowerCase();
|
|
1184
|
+
promoted[key] = r.requestedAt;
|
|
1185
|
+
const p = plans.find((pl) => pl.unnamed !== true && pl.name.toLowerCase() === key);
|
|
1186
|
+
if (p === undefined) {
|
|
1187
|
+
deps.log(`deliver : ${e.goalId}: promote to ${r.environment} not acted on — no environment has that name`);
|
|
1188
|
+
continue;
|
|
1189
|
+
}
|
|
1190
|
+
if (p.test === true) {
|
|
1191
|
+
deps.log(`deliver : ${e.goalId}: promote to ${p.name} not acted on — it is the test environment`);
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1194
|
+
const i = rows.findIndex((row) => row.name.toLowerCase() === key);
|
|
1195
|
+
if (i >= 0 && rows[i].state === 'deployed')
|
|
1196
|
+
continue;
|
|
1197
|
+
deps.log(`deliver : promoting ${sha?.slice(0, 12) ?? e.goalId} to ${p.name} (asked by ${r.requestedBy} at ${r.requestedAt})`);
|
|
1198
|
+
const row = await deployOne(deps, e, p, sha, at, asked);
|
|
1199
|
+
const said = { ...row, detail: `promoted by ${r.requestedBy}: ${row.detail ?? row.state}`.slice(0, 600) };
|
|
1200
|
+
if (i >= 0)
|
|
1201
|
+
rows[i] = said;
|
|
1202
|
+
else
|
|
1203
|
+
rows.push(said);
|
|
1204
|
+
}
|
|
1205
|
+
return { ...e, environments: rows, promoted };
|
|
1206
|
+
}
|
|
1207
|
+
/** Looply's own deploy step for ONE environment, and the row it leaves. */
|
|
1208
|
+
async function deployOne(deps, e, p, sha, at, asked) {
|
|
1209
|
+
const base = e.projectBase ?? '';
|
|
1210
|
+
const row = (state, detail, when = null) => envRow(p, sha, at, state, detail, when);
|
|
1211
|
+
const d = p.deploy;
|
|
1212
|
+
if (d.kind === 'none')
|
|
1213
|
+
return row('waiting', `no deploy step is configured for ${p.name}`);
|
|
1214
|
+
if (d.kind === 'merge-deploys')
|
|
1215
|
+
return row('unconfirmed', `merged into ${base}, which should deploy it; not confirmed yet`);
|
|
1216
|
+
if (d.kind === 'push') {
|
|
1217
|
+
if (sha === null)
|
|
1218
|
+
return row('failed', 'merged, but GitHub did not say which commit the merge made, so nothing was pushed to deploy');
|
|
1219
|
+
// 🔴 THE HEROKU LOGIN: the push goes nowhere unless the CLI is signed in as the account named.
|
|
1220
|
+
if (d.herokuAccount !== undefined) {
|
|
1221
|
+
const who = deps.heroku
|
|
1222
|
+
? await deps.heroku.whoami().catch((err) => ({ ok: false, because: err.message }))
|
|
1223
|
+
: { ok: false, because: 'the Heroku CLI is not available to Looply here' };
|
|
1224
|
+
if (!who.ok)
|
|
1225
|
+
return row('failed', `not pushed: could not tell which account the Heroku CLI is signed in as (${who.because})`);
|
|
1226
|
+
if (who.account.toLowerCase() !== d.herokuAccount.toLowerCase())
|
|
1227
|
+
return row('failed', `not pushed: Heroku CLI is signed in as ${who.account}, not ${d.herokuAccount}`);
|
|
1228
|
+
}
|
|
1229
|
+
deps.log(`deliver : deploying ${sha.slice(0, 12)} to ${d.remote} ${d.branch} (${p.name})`);
|
|
1230
|
+
const r = await deps.deployPush(d.remote, d.branch, sha, base);
|
|
1231
|
+
if (!r.ok)
|
|
1232
|
+
return row('failed', `pushing to ${d.remote} ${d.branch} failed: ${r.because}`);
|
|
1233
|
+
const pushed = `pushed ${sha.slice(0, 12)} to ${d.branch} on ${d.remote}`;
|
|
1234
|
+
if (d.herokuApp === undefined)
|
|
1235
|
+
return row('deployed', pushed);
|
|
1236
|
+
asked.add(p.name);
|
|
1237
|
+
const c = await herokuRelease(deps, d.herokuApp, sha, base);
|
|
1238
|
+
return c.state === 'deployed' ? row('deployed', `${pushed}; ${c.detail}`, c.at) : row(c.state, `${pushed}, but ${c.detail}`, c.at);
|
|
1239
|
+
}
|
|
1240
|
+
const command = deps.release();
|
|
1241
|
+
if (command === null)
|
|
1242
|
+
return row('failed', 'merged; not deployed — no release step is declared (deliver.release in .looply/config.yaml)');
|
|
1243
|
+
deps.log(`deliver : running the declared release step for ${e.goalId} (${p.name})`);
|
|
1244
|
+
const r = await deps.runRelease(command, {
|
|
1245
|
+
LOOPLY_GOAL_ID: e.goalId,
|
|
1246
|
+
LOOPLY_BRANCH: e.branch,
|
|
1247
|
+
LOOPLY_BASE_BRANCH: base,
|
|
1248
|
+
LOOPLY_MERGED_SHA: sha ?? '',
|
|
1249
|
+
LOOPLY_PULL_REQUEST_URL: e.pullRequestUrl ?? '',
|
|
1250
|
+
LOOPLY_ENVIRONMENT: p.name,
|
|
1251
|
+
});
|
|
1252
|
+
return r.code === 0 ? row('deployed', 'the declared release step finished') : row('failed', `the release step exited ${r.code}: ${r.tail.slice(-300)}`);
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* IS THIS COMMIT WHAT A HEROKU APP RUNS? Its newest release (`heroku releases -a <app> -n 1 --json`,
|
|
1256
|
+
* never `releases:info`) names a commit — its slug's, else the `Deploy <sha>` of its description.
|
|
1257
|
+
* That commit IS this one, or CONTAINS it (a later release carries it): `deployed`. A release of this
|
|
1258
|
+
* very commit that failed: `failed`. Anything else, or Heroku not answering: `unconfirmed`.
|
|
1259
|
+
*/
|
|
1260
|
+
async function herokuRelease(deps, app, sha, base) {
|
|
1261
|
+
const unconfirmed = (detail) => ({ state: 'unconfirmed', at: null, detail });
|
|
1262
|
+
if (!deps.heroku)
|
|
1263
|
+
return unconfirmed(`the Heroku CLI is not available to read ${app}'s releases`);
|
|
1264
|
+
const r = await deps.heroku.latestRelease(app).catch((err) => ({ ok: false, because: err.message }));
|
|
1265
|
+
if (!r.ok)
|
|
1266
|
+
return unconfirmed(`Heroku's releases of ${app} could not be read (${r.because})`);
|
|
1267
|
+
if (r.release === null)
|
|
1268
|
+
return unconfirmed(`${app} has no release yet`);
|
|
1269
|
+
const rel = r.release;
|
|
1270
|
+
const v = rel.version === null ? 'its newest release' : `v${rel.version}`;
|
|
1271
|
+
const named = (0, heroku_cli_1.releaseCommit)(rel);
|
|
1272
|
+
const exact = rel.description.includes(sha.slice(0, 7)) || (named !== null && (sha.startsWith(named) || named.startsWith(sha)));
|
|
1273
|
+
const later = !exact && named !== null && deps.git.contains !== undefined && (await deps.git.contains(sha, named, base).catch(() => false));
|
|
1274
|
+
const of = `Heroku release ${v} of ${app}${named === null ? '' : ` (${named.slice(0, 8)})`}`;
|
|
1275
|
+
if (!exact && !later) {
|
|
1276
|
+
return unconfirmed(named === null ? `${app}'s newest release (${v}: "${rel.description}") does not name a commit` : `${of} does not contain ${sha.slice(0, 12)} yet`);
|
|
1277
|
+
}
|
|
1278
|
+
if (rel.status === 'failed')
|
|
1279
|
+
return exact ? { state: 'failed', at: rel.at, detail: `${of} failed` } : unconfirmed(`${of} contains it, but failed`);
|
|
1280
|
+
if (rel.status === 'pending')
|
|
1281
|
+
return { state: 'deploying', at: rel.at, detail: `${of} is in progress` };
|
|
1282
|
+
return { state: 'deployed', at: rel.at, detail: `${of} ${exact ? 'is' : 'contains'} this commit` };
|
|
1283
|
+
}
|
|
1284
|
+
/**
|
|
1285
|
+
* DEPLOYS OF A LATER COMMIT THAT CONTAINS THIS ONE. A ticket merged by hand into one commit, or a
|
|
1286
|
+
* deploy of main a little later, carries this commit without being it: GitHub's newest successful
|
|
1287
|
+
* deployment of a named environment, and a Heroku app's newest release, count when they contain it.
|
|
1288
|
+
*/
|
|
1289
|
+
async function laterDeploys(deps, api, e, plans, sha, base, at, asked) {
|
|
1290
|
+
const open = (r) => r.state !== 'deployed' && r.state !== 'failed';
|
|
1291
|
+
const rows = [...(e.environments ?? [])];
|
|
1292
|
+
if (!rows.some(open))
|
|
1293
|
+
return e;
|
|
1294
|
+
if (api?.latestDeployments && deps.git.contains !== undefined) {
|
|
1295
|
+
const latest = await api.latestDeployments().catch(() => null);
|
|
1296
|
+
for (const [i, row] of rows.entries()) {
|
|
1297
|
+
const plan = plans.find((p) => p.name === row.name);
|
|
1298
|
+
if (!open(row) || latest === null || plan === undefined || plan.unnamed === true)
|
|
1299
|
+
continue;
|
|
1300
|
+
const d = latest.find((x) => x.environment.toLowerCase() === row.name.toLowerCase());
|
|
1301
|
+
if (d === undefined || d.state !== 'success' || d.sha === sha || !(await deps.git.contains(sha, d.sha, base).catch(() => false)))
|
|
1302
|
+
continue;
|
|
1303
|
+
rows[i] = {
|
|
1304
|
+
...row,
|
|
1305
|
+
state: 'deployed',
|
|
1306
|
+
at: Number.isNaN(Date.parse(d.at)) ? at : new Date(d.at).toISOString(),
|
|
1307
|
+
url: d.url ?? row.url,
|
|
1308
|
+
detail: `GitHub reports ${d.sha.slice(0, 12)}, which contains this commit, deployed to ${d.environment}${d.description ? `: ${d.description}` : ''}`.slice(0, 600),
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
for (const [i, row] of rows.entries()) {
|
|
1313
|
+
const plan = plans.find((p) => p.name === row.name);
|
|
1314
|
+
const d = plan?.deploy;
|
|
1315
|
+
// A row this pass's own push already asked Heroku about is not asked again.
|
|
1316
|
+
if (!open(row) || d?.kind !== 'push' || d.herokuApp === undefined || asked.has(row.name))
|
|
1317
|
+
continue;
|
|
1318
|
+
const c = await herokuRelease(deps, d.herokuApp, sha, base);
|
|
1319
|
+
if (c.state === 'unconfirmed')
|
|
1320
|
+
continue;
|
|
1321
|
+
rows[i] = { ...row, state: c.state, at: c.at ?? at, detail: c.detail.slice(0, 600) };
|
|
1322
|
+
}
|
|
1323
|
+
return { ...e, environments: rows };
|
|
1324
|
+
}
|
|
1100
1325
|
/** GitHub's word on one environment, when it has one and the row is not already settled. */
|
|
1101
1326
|
function fromGitHub(row, plans, found) {
|
|
1102
1327
|
if (row.state === 'deployed' || row.state === 'failed')
|