mandrel-platform 0.24.0 → 0.25.0
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/package.json +2 -2
- package/scripts/check-pin-drift.mjs +66 -1
- package/scripts/check-pin-drift.test.mjs +125 -0
- package/scripts/deploy-boot-smoke.mjs +116 -36
- package/scripts/deploy-boot-smoke.test.mjs +138 -17
- package/scripts/platform-repair.mjs +38 -2
- package/scripts/platform-repair.test.mjs +113 -0
- package/scripts/semgrep-requirements.txt +169 -0
- package/templates/workflows/deploy-staging-run.yml +44 -4
- package/templates/workflows/deploy-staging.yml +68 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mandrel-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Shared CI/deploy workflows, composite toolchain action, npm config package, Renovate preset, and operator runbook templates.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"provenance": true
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"mandrel": "^1.
|
|
47
|
+
"mandrel": "^1.83.0"
|
|
48
48
|
},
|
|
49
49
|
"scripts": {
|
|
50
50
|
"typecheck": "node --input-type=module --eval 'process.exit(0)'",
|
|
@@ -979,18 +979,61 @@ export function hasDrift(report) {
|
|
|
979
979
|
return report.results.some((r) => r.error || r.drift);
|
|
980
980
|
}
|
|
981
981
|
|
|
982
|
+
/**
|
|
983
|
+
* Is EVERY configured consumer an `error` row (M11)? This is the signature of a
|
|
984
|
+
* dead cross-repo credential: a PIN_DRIFT_TOKEN that was *provided* but has
|
|
985
|
+
* expired (fine-grained PATs always expire) can no longer read ANY consumer, so
|
|
986
|
+
* every `fetchConsumerWorkflows` call fails closed to an `error` row. Contrast
|
|
987
|
+
* the not-yet-provisioned bootstrap case: there the token is *absent*, the run
|
|
988
|
+
* legitimately can't read the private consumers, and that benign state must keep
|
|
989
|
+
* its current exit-0 behavior. The caller distinguishes the two by whether the
|
|
990
|
+
* token was provided (see `runCli`'s `tokenProvided`); this predicate only
|
|
991
|
+
* answers "did every row error", which — given a provided token — means the
|
|
992
|
+
* credential died rather than "no drift".
|
|
993
|
+
*
|
|
994
|
+
* Requires at least one consumer (an empty registry is vacuously not a
|
|
995
|
+
* dead-credential signal).
|
|
996
|
+
*
|
|
997
|
+
* @param {{ results: Array<{ error?: string }> }} report
|
|
998
|
+
* @returns {boolean}
|
|
999
|
+
*/
|
|
1000
|
+
export function allConsumersErrored(report) {
|
|
1001
|
+
const results = Array.isArray(report.results) ? report.results : [];
|
|
1002
|
+
return results.length > 0 && results.every((r) => Boolean(r.error));
|
|
1003
|
+
}
|
|
1004
|
+
|
|
982
1005
|
// ---------------------------------------------------------------------------
|
|
983
1006
|
// CLI entry
|
|
984
1007
|
// ---------------------------------------------------------------------------
|
|
985
1008
|
|
|
1009
|
+
/**
|
|
1010
|
+
* Whether the cross-repo `PIN_DRIFT_TOKEN` was PROVIDED (non-empty) to the run
|
|
1011
|
+
* (M11). The scheduled/dispatch workflow reads consumers over the `gh` CLI with
|
|
1012
|
+
* `GH_TOKEN: ${{ secrets.PIN_DRIFT_TOKEN || github.token }}` — the built-in
|
|
1013
|
+
* `github.token` fallback can only read THIS repo, so cross-repo reads fail
|
|
1014
|
+
* closed to `error` rows both when the token is absent (bootstrap) AND when it
|
|
1015
|
+
* is provided-but-dead (expired PAT). This env var is the only signal that
|
|
1016
|
+
* distinguishes the two: the workflow sets it to the raw secret so an empty
|
|
1017
|
+
* value ⇒ absent (bootstrap, benign) and a non-empty value ⇒ provided (a
|
|
1018
|
+
* total error sweep then means the credential died).
|
|
1019
|
+
*
|
|
1020
|
+
* @param {Record<string, string | undefined>} env
|
|
1021
|
+
* @returns {boolean}
|
|
1022
|
+
*/
|
|
1023
|
+
export function pinDriftTokenProvided(env) {
|
|
1024
|
+
return typeof env.PIN_DRIFT_TOKEN === "string" && env.PIN_DRIFT_TOKEN.length > 0;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
986
1027
|
/**
|
|
987
1028
|
* @param {{
|
|
988
1029
|
* argv?: string[],
|
|
989
1030
|
* cwd?: string,
|
|
990
1031
|
* stdout?: { write: (s: string) => void },
|
|
991
1032
|
* stderr?: { write: (s: string) => void },
|
|
1033
|
+
* env?: Record<string, string | undefined>,
|
|
992
1034
|
* runGh?: (args: string[]) => string,
|
|
993
1035
|
* summaryPath?: string | undefined,
|
|
1036
|
+
* tokenProvided?: boolean,
|
|
994
1037
|
* nowMs?: number,
|
|
995
1038
|
* }} [opts]
|
|
996
1039
|
* @returns {number} exit code
|
|
@@ -1000,8 +1043,10 @@ export function runCli({
|
|
|
1000
1043
|
cwd = process.cwd(),
|
|
1001
1044
|
stdout = process.stdout,
|
|
1002
1045
|
stderr = process.stderr,
|
|
1046
|
+
env = process.env,
|
|
1003
1047
|
runGh = defaultGhRunner,
|
|
1004
1048
|
summaryPath = process.env.GITHUB_STEP_SUMMARY,
|
|
1049
|
+
tokenProvided = pinDriftTokenProvided(env),
|
|
1005
1050
|
nowMs = Date.now(),
|
|
1006
1051
|
} = {}) {
|
|
1007
1052
|
const { config: configRel, json, strict } = parseArgv(argv);
|
|
@@ -1025,9 +1070,19 @@ export function runCli({
|
|
|
1025
1070
|
|
|
1026
1071
|
const report = buildReport(config, runGh, nowMs);
|
|
1027
1072
|
const drift = hasDrift(report);
|
|
1073
|
+
// M11: a PROVIDED-but-dead PIN_DRIFT_TOKEN (expired PAT) can no longer read
|
|
1074
|
+
// ANY consumer, so every row fails closed to `error`. That is a credential
|
|
1075
|
+
// failure, NOT the benign not-yet-provisioned bootstrap (token absent) — fail
|
|
1076
|
+
// the run unconditionally (even without --strict, which the scheduled path
|
|
1077
|
+
// can never pass) so the death is loud instead of a green no-op. The absent-
|
|
1078
|
+
// token bootstrap keeps its current behavior: tokenProvided is false, so this
|
|
1079
|
+
// branch never fires and the run exits per the drift/--strict rules below.
|
|
1080
|
+
const deadCredential = tokenProvided && allConsumersErrored(report);
|
|
1028
1081
|
|
|
1029
1082
|
if (json) {
|
|
1030
|
-
stdout.write(
|
|
1083
|
+
stdout.write(
|
|
1084
|
+
`${JSON.stringify({ kind: "pin-drift-report", drift, deadCredential, ...report }, null, 2)}\n`,
|
|
1085
|
+
);
|
|
1031
1086
|
} else {
|
|
1032
1087
|
const text = renderReport(report);
|
|
1033
1088
|
stdout.write(`${text}\n`);
|
|
@@ -1042,6 +1097,16 @@ export function runCli({
|
|
|
1042
1097
|
}
|
|
1043
1098
|
}
|
|
1044
1099
|
|
|
1100
|
+
if (deadCredential) {
|
|
1101
|
+
stderr.write(
|
|
1102
|
+
"::error::[pin-drift] PIN_DRIFT_TOKEN was provided but every cross-repo " +
|
|
1103
|
+
"consumer read errored — the credential is dead (likely an expired " +
|
|
1104
|
+
"fine-grained PAT), not a not-yet-provisioned bootstrap. Rotate the " +
|
|
1105
|
+
"token. See docs/runbooks/pin-drift-dashboard.md.\n",
|
|
1106
|
+
);
|
|
1107
|
+
return 1;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1045
1110
|
if (strict && drift) {
|
|
1046
1111
|
stderr.write(`[pin-drift] ❌ drift detected (--strict)\n`);
|
|
1047
1112
|
return 1;
|
|
@@ -20,6 +20,7 @@ import { join } from "node:path";
|
|
|
20
20
|
import { test } from "node:test";
|
|
21
21
|
|
|
22
22
|
import {
|
|
23
|
+
allConsumersErrored,
|
|
23
24
|
buildReport,
|
|
24
25
|
classifyNpmPin,
|
|
25
26
|
classifyStaleLiterals,
|
|
@@ -34,6 +35,7 @@ import {
|
|
|
34
35
|
isWithinReleaseAgeWindow,
|
|
35
36
|
parseDurationMs,
|
|
36
37
|
parseSemver,
|
|
38
|
+
pinDriftTokenProvided,
|
|
37
39
|
renderReport,
|
|
38
40
|
resolveLatestRelease,
|
|
39
41
|
runCli,
|
|
@@ -883,3 +885,126 @@ test("runCli --strict DOES exit 1 once the held release ages out", () => {
|
|
|
883
885
|
rmSync(cfgDir, { recursive: true, force: true });
|
|
884
886
|
}
|
|
885
887
|
});
|
|
888
|
+
|
|
889
|
+
// ---------------------------------------------------------------------------
|
|
890
|
+
// M11 — provided-but-dead PIN_DRIFT_TOKEN (expired PAT) vs. not-yet-provisioned
|
|
891
|
+
// bootstrap. A dead credential can read NO consumer, so every row fails closed
|
|
892
|
+
// to an `error` row; that must hard-fail EVEN without --strict (the scheduled
|
|
893
|
+
// path can never pass --strict). The absent-token bootstrap — identical row
|
|
894
|
+
// shape but token unset — must keep its benign exit-0.
|
|
895
|
+
// (temp/audits/workflow-robustness-review-2026-07-05.md M11)
|
|
896
|
+
// ---------------------------------------------------------------------------
|
|
897
|
+
|
|
898
|
+
const DEAD_CRED_CONFIG = {
|
|
899
|
+
platformRepo: PLATFORM,
|
|
900
|
+
consumers: [
|
|
901
|
+
{ name: "c1", repo: "o/c1", branch: "main" },
|
|
902
|
+
{ name: "c2", repo: "o/c2", branch: "main" },
|
|
903
|
+
],
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* A gh runner where the platform's own release resolution succeeds (that read
|
|
908
|
+
* uses the built-in token, which CAN read this repo) but EVERY cross-repo
|
|
909
|
+
* consumer workflow-listing call fails with a non-404 (auth/transport) — the
|
|
910
|
+
* exact shape of a dead cross-repo PAT. Every consumer therefore becomes an
|
|
911
|
+
* `error` row.
|
|
912
|
+
*/
|
|
913
|
+
function makeAllConsumersFailRunGh() {
|
|
914
|
+
return (args) => {
|
|
915
|
+
const path = args[1];
|
|
916
|
+
if (path === `repos/${PLATFORM}/releases/latest`) {
|
|
917
|
+
return JSON.stringify({ tag_name: TAG });
|
|
918
|
+
}
|
|
919
|
+
if (path === `repos/${PLATFORM}/git/ref/tags/${TAG}`) {
|
|
920
|
+
return JSON.stringify({ object: { sha: LATEST_SHA, type: "commit" } });
|
|
921
|
+
}
|
|
922
|
+
if (/\/contents\/\.github\/workflows/.test(path)) {
|
|
923
|
+
// 403/5xx (not 404) → fail-closed to an `error` row, mirroring an expired
|
|
924
|
+
// PAT that can no longer read the private consumer repo.
|
|
925
|
+
throw ghHttpError(403, "Forbidden");
|
|
926
|
+
}
|
|
927
|
+
throw new Error(`unexpected gh api path: ${path}`);
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
test("pinDriftTokenProvided: non-empty ⇒ true, empty/absent ⇒ false", () => {
|
|
932
|
+
assert.equal(pinDriftTokenProvided({ PIN_DRIFT_TOKEN: "ghp_live" }), true);
|
|
933
|
+
assert.equal(pinDriftTokenProvided({ PIN_DRIFT_TOKEN: "" }), false);
|
|
934
|
+
assert.equal(pinDriftTokenProvided({}), false);
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
test("allConsumersErrored: true only when every row errored and ≥1 consumer", () => {
|
|
938
|
+
const report = buildReport(DEAD_CRED_CONFIG, makeAllConsumersFailRunGh());
|
|
939
|
+
assert.equal(allConsumersErrored(report), true);
|
|
940
|
+
// A single clean row flips it back to false — that is drift/partial, not a
|
|
941
|
+
// dead credential.
|
|
942
|
+
assert.equal(
|
|
943
|
+
allConsumersErrored({ results: [{ error: "x" }, { drift: true }] }),
|
|
944
|
+
false,
|
|
945
|
+
);
|
|
946
|
+
assert.equal(allConsumersErrored({ results: [] }), false);
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
test("runCli: PROVIDED-but-dead PIN_DRIFT_TOKEN (all rows error) exits 1 with ::error:: even without --strict", () => {
|
|
950
|
+
cfgDir = mkdtempSync(join(tmpdir(), "pin-drift-dead-"));
|
|
951
|
+
const p = join(cfgDir, "consumers.json");
|
|
952
|
+
writeFileSync(p, JSON.stringify(DEAD_CRED_CONFIG));
|
|
953
|
+
const stderr = capture();
|
|
954
|
+
try {
|
|
955
|
+
const code = runCli({
|
|
956
|
+
argv: ["--config", p], // NOTE: no --strict.
|
|
957
|
+
env: { PIN_DRIFT_TOKEN: "ghp_expired" },
|
|
958
|
+
runGh: makeAllConsumersFailRunGh(),
|
|
959
|
+
stdout: capture(),
|
|
960
|
+
stderr,
|
|
961
|
+
summaryPath: undefined,
|
|
962
|
+
});
|
|
963
|
+
assert.equal(code, 1);
|
|
964
|
+
assert.match(stderr.text(), /::error::/);
|
|
965
|
+
assert.match(stderr.text(), /credential is dead/);
|
|
966
|
+
} finally {
|
|
967
|
+
rmSync(cfgDir, { recursive: true, force: true });
|
|
968
|
+
}
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
test("runCli: dead-credential also surfaces in the --json envelope (deadCredential:true)", () => {
|
|
972
|
+
cfgDir = mkdtempSync(join(tmpdir(), "pin-drift-dead-json-"));
|
|
973
|
+
const p = join(cfgDir, "consumers.json");
|
|
974
|
+
writeFileSync(p, JSON.stringify(DEAD_CRED_CONFIG));
|
|
975
|
+
const stdout = capture();
|
|
976
|
+
try {
|
|
977
|
+
const code = runCli({
|
|
978
|
+
argv: ["--config", p, "--json"],
|
|
979
|
+
env: { PIN_DRIFT_TOKEN: "ghp_expired" },
|
|
980
|
+
runGh: makeAllConsumersFailRunGh(),
|
|
981
|
+
stdout,
|
|
982
|
+
stderr: capture(),
|
|
983
|
+
summaryPath: undefined,
|
|
984
|
+
});
|
|
985
|
+
assert.equal(code, 1);
|
|
986
|
+
const envelope = JSON.parse(stdout.text());
|
|
987
|
+
assert.equal(envelope.deadCredential, true);
|
|
988
|
+
} finally {
|
|
989
|
+
rmSync(cfgDir, { recursive: true, force: true });
|
|
990
|
+
}
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
test("runCli: ABSENT PIN_DRIFT_TOKEN bootstrap (all rows error, token unset) stays exit 0 — benign", () => {
|
|
994
|
+
cfgDir = mkdtempSync(join(tmpdir(), "pin-drift-bootstrap-"));
|
|
995
|
+
const p = join(cfgDir, "consumers.json");
|
|
996
|
+
writeFileSync(p, JSON.stringify(DEAD_CRED_CONFIG));
|
|
997
|
+
try {
|
|
998
|
+
const code = runCli({
|
|
999
|
+
argv: ["--config", p], // no --strict, no token.
|
|
1000
|
+
env: {}, // PIN_DRIFT_TOKEN absent → not-yet-provisioned bootstrap.
|
|
1001
|
+
runGh: makeAllConsumersFailRunGh(),
|
|
1002
|
+
stdout: capture(),
|
|
1003
|
+
stderr: capture(),
|
|
1004
|
+
summaryPath: undefined,
|
|
1005
|
+
});
|
|
1006
|
+
assert.equal(code, 0);
|
|
1007
|
+
} finally {
|
|
1008
|
+
rmSync(cfgDir, { recursive: true, force: true });
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
@@ -40,10 +40,15 @@
|
|
|
40
40
|
* appended to $GITHUB_ENV so the workflow's `Rollback failed workers` step
|
|
41
41
|
* fires. Exit code 1 on any smoke failure.
|
|
42
42
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
43
|
+
* Subdomain derivation (M14): the workers.dev account subdomain is derived
|
|
44
|
+
* via the Cloudflare REST endpoint
|
|
45
|
+
* `GET /accounts/{account_id}/workers/subdomain` using the in-scope
|
|
46
|
+
* CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID — NOT `wrangler whoami`. This
|
|
47
|
+
* removes the root-wrangler dependency for probe-only consumers: every real
|
|
48
|
+
* consumer is a pnpm workspace with wrangler in an app sub-package (not the
|
|
49
|
+
* root), so the former `pnpm exec wrangler whoami` derivation failed the
|
|
50
|
+
* smoke preflight AFTER the worker already deployed. The REST call needs no
|
|
51
|
+
* wrangler at all.
|
|
47
52
|
*
|
|
48
53
|
* Environment contract (all read from process.env):
|
|
49
54
|
* DEPLOYED_WORKERS csv of deployed worker names (required)
|
|
@@ -53,6 +58,8 @@
|
|
|
53
58
|
* WORKERS_DEV_SUBDOMAIN explicit workers.dev slug (optional)
|
|
54
59
|
* VERIFY_COMMIT_SHA 'true' to assert the health JSON version field
|
|
55
60
|
* EXPECTED_SHA the SHA verify-commit-sha asserts (github.sha)
|
|
61
|
+
* CLOUDFLARE_API_TOKEN CF API token for the subdomain REST derivation
|
|
62
|
+
* CLOUDFLARE_ACCOUNT_ID CF account id for the subdomain REST derivation
|
|
56
63
|
* SMOKE_FAILED_FILE rollback-list path (default: a freshly-created
|
|
57
64
|
* private temp dir via mkdtemp — never a predictable
|
|
58
65
|
* world-writable path; CI sets this explicitly)
|
|
@@ -61,13 +68,16 @@
|
|
|
61
68
|
* Exit codes:
|
|
62
69
|
* 0 — every probe passed.
|
|
63
70
|
* 1 — a probe failed (rollback list written), or the probe target could
|
|
64
|
-
* not be resolved (no subdomain derivable — no rollback list)
|
|
71
|
+
* not be resolved (no subdomain derivable — no rollback list), or an
|
|
72
|
+
* unhandled error crashed the run (terminal-write-before-exit — the
|
|
73
|
+
* rollback file + smoke_failed=true flag are still written so the
|
|
74
|
+
* workflow's rollback step fires, M2).
|
|
65
75
|
*/
|
|
66
76
|
|
|
67
77
|
import { writeFileSync, appendFileSync, mkdtempSync } from "node:fs";
|
|
68
78
|
import { tmpdir } from "node:os";
|
|
69
79
|
import { join } from "node:path";
|
|
70
|
-
import {
|
|
80
|
+
import { spawnSync } from "node:child_process";
|
|
71
81
|
|
|
72
82
|
// ---------------------------------------------------------------------------
|
|
73
83
|
// Pure helpers (unit-tested)
|
|
@@ -87,12 +97,28 @@ export function parseSmokePaths(csv) {
|
|
|
87
97
|
}
|
|
88
98
|
|
|
89
99
|
/**
|
|
90
|
-
* Extract the workers.dev account subdomain slug from
|
|
91
|
-
*
|
|
100
|
+
* Extract the workers.dev account subdomain slug from a Cloudflare REST
|
|
101
|
+
* `GET /accounts/{account_id}/workers/subdomain` response body (M14). The API
|
|
102
|
+
* returns `{ success, result: { name: "<slug>" } }`; this reads the top-level
|
|
103
|
+
* `result.name`. Returns null when the body is not JSON, not the expected
|
|
104
|
+
* shape, unsuccessful, or the name is missing / not a non-empty string.
|
|
105
|
+
* Deliberately tolerant of a full `<slug>.workers.dev` value (some responses
|
|
106
|
+
* echo the host) by stripping a trailing `.workers.dev`.
|
|
92
107
|
*/
|
|
93
|
-
export function extractSubdomain(
|
|
94
|
-
|
|
95
|
-
|
|
108
|
+
export function extractSubdomain(responseBody) {
|
|
109
|
+
let parsed;
|
|
110
|
+
try {
|
|
111
|
+
parsed = JSON.parse(responseBody);
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
116
|
+
if (parsed.success === false) return null;
|
|
117
|
+
const result = parsed.result;
|
|
118
|
+
if (result === null || typeof result !== "object" || Array.isArray(result)) return null;
|
|
119
|
+
const name = result.name;
|
|
120
|
+
if (typeof name !== "string" || name.length === 0) return null;
|
|
121
|
+
return name.replace(/\.workers\.dev$/, "");
|
|
96
122
|
}
|
|
97
123
|
|
|
98
124
|
/**
|
|
@@ -199,7 +225,7 @@ export async function runSmoke(env, deps = {}) {
|
|
|
199
225
|
log = (line) => process.stdout.write(`${line}\n`),
|
|
200
226
|
probe = probeUrl,
|
|
201
227
|
runShell = defaultRunShell,
|
|
202
|
-
|
|
228
|
+
deriveSubdomain = defaultDeriveSubdomain,
|
|
203
229
|
} = deps;
|
|
204
230
|
|
|
205
231
|
const workers = parseCsv(env.DEPLOYED_WORKERS);
|
|
@@ -228,15 +254,20 @@ export async function runSmoke(env, deps = {}) {
|
|
|
228
254
|
// ----- Built-in probe -----
|
|
229
255
|
// Resolve the workers.dev subdomain slug. NEVER the account ID (a UUID) —
|
|
230
256
|
// workers.dev subdomains are keyed by the account name/slug. Prefer the
|
|
231
|
-
// explicit input, else derive from
|
|
257
|
+
// explicit input, else derive from the Cloudflare REST endpoint
|
|
258
|
+
// GET /accounts/{account_id}/workers/subdomain (M14 — no wrangler needed).
|
|
232
259
|
let subdomain = (env.WORKERS_DEV_SUBDOMAIN ?? "").trim();
|
|
233
260
|
if (!subdomain && !smokeBaseUrl) {
|
|
234
|
-
log("::group::Deriving workers.dev subdomain from
|
|
235
|
-
subdomain =
|
|
261
|
+
log("::group::Deriving workers.dev subdomain from the Cloudflare REST API");
|
|
262
|
+
subdomain = await deriveSubdomain({
|
|
263
|
+
accountId: (env.CLOUDFLARE_ACCOUNT_ID ?? "").trim(),
|
|
264
|
+
apiToken: (env.CLOUDFLARE_API_TOKEN ?? "").trim(),
|
|
265
|
+
});
|
|
236
266
|
if (!subdomain) {
|
|
237
267
|
log(
|
|
238
|
-
"::error::Could not derive workers.dev subdomain from
|
|
239
|
-
"
|
|
268
|
+
"::error::Could not derive workers.dev subdomain from the Cloudflare REST API " +
|
|
269
|
+
"(GET /accounts/{account_id}/workers/subdomain). Check CLOUDFLARE_ACCOUNT_ID / " +
|
|
270
|
+
"CLOUDFLARE_API_TOKEN, or pass workers_dev_subdomain / smoke_base_url explicitly."
|
|
240
271
|
);
|
|
241
272
|
log("::endgroup::");
|
|
242
273
|
// No rollback list: the target could not be resolved, so nothing was
|
|
@@ -306,18 +337,30 @@ function defaultRunShell(command, extraEnv) {
|
|
|
306
337
|
return res.status ?? 1;
|
|
307
338
|
}
|
|
308
339
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
340
|
+
/**
|
|
341
|
+
* Derive the workers.dev subdomain slug via the Cloudflare REST endpoint
|
|
342
|
+
* `GET /accounts/{account_id}/workers/subdomain` (M14). Uses the in-scope
|
|
343
|
+
* CLOUDFLARE_* creds — no wrangler. Any failure (missing creds, network
|
|
344
|
+
* error, non-2xx, unparsable body) is tolerated and returns null, which the
|
|
345
|
+
* caller surfaces as the "could not derive" error above. fetchImpl is
|
|
346
|
+
* injectable for the test suite.
|
|
347
|
+
*/
|
|
348
|
+
export async function defaultDeriveSubdomain({ accountId, apiToken }, fetchImpl = fetch) {
|
|
349
|
+
if (!accountId || !apiToken) return null;
|
|
350
|
+
const url = `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/workers/subdomain`;
|
|
313
351
|
try {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
352
|
+
const res = await fetchImpl(url, {
|
|
353
|
+
method: "GET",
|
|
354
|
+
headers: {
|
|
355
|
+
Authorization: `Bearer ${apiToken}`,
|
|
356
|
+
Accept: "application/json",
|
|
357
|
+
},
|
|
358
|
+
signal: AbortSignal.timeout(15000),
|
|
318
359
|
});
|
|
360
|
+
if (!res.ok) return null;
|
|
361
|
+
return extractSubdomain(await res.text());
|
|
319
362
|
} catch {
|
|
320
|
-
return
|
|
363
|
+
return null;
|
|
321
364
|
}
|
|
322
365
|
}
|
|
323
366
|
|
|
@@ -341,18 +384,55 @@ export function resolveFailedFile(env, mkdtempImpl = mkdtempSync) {
|
|
|
341
384
|
return join(mkdtempImpl(join(tmpdir(), "deploy-boot-smoke-")), "smoke-failed-workers.txt");
|
|
342
385
|
}
|
|
343
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Write the rollback terminal state: the failed-worker list to `failedFile`
|
|
389
|
+
* (overwrite, never append) and `smoke_failed=true` to $GITHUB_ENV so the
|
|
390
|
+
* workflow's rollback step fires. No-op when `failedWorkers` is empty.
|
|
391
|
+
* Extracted so both the normal smoke-failure path AND the crash path (M2 —
|
|
392
|
+
* terminal-write-before-exit) share one writer, and so the write is
|
|
393
|
+
* unit-testable via injected fs seams.
|
|
394
|
+
*
|
|
395
|
+
* Overwrite rationale: a reused self-hosted runner may carry a stale list
|
|
396
|
+
* from a previous run, and rolling back workers this run never deployed would
|
|
397
|
+
* widen the blast radius.
|
|
398
|
+
*/
|
|
399
|
+
export function writeRollbackState(
|
|
400
|
+
failedFile,
|
|
401
|
+
failedWorkers,
|
|
402
|
+
{ githubEnv, writeFile = writeFileSync, appendFile = appendFileSync } = {}
|
|
403
|
+
) {
|
|
404
|
+
if (!failedWorkers || failedWorkers.length === 0) return;
|
|
405
|
+
writeFile(failedFile, `${failedWorkers.join("\n")}\n`);
|
|
406
|
+
if (githubEnv) {
|
|
407
|
+
appendFile(githubEnv, "smoke_failed=true\n");
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
344
411
|
async function main() {
|
|
345
412
|
const failedFile = resolveFailedFile(process.env);
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
413
|
+
let exitCode;
|
|
414
|
+
try {
|
|
415
|
+
const result = await runSmoke(process.env);
|
|
416
|
+
exitCode = result.exitCode;
|
|
417
|
+
writeRollbackState(failedFile, result.failedWorkers, {
|
|
418
|
+
githubEnv: process.env.GITHUB_ENV,
|
|
419
|
+
});
|
|
420
|
+
} catch (err) {
|
|
421
|
+
// Crash path (M2): an unhandled error must NOT leave a deployed worker
|
|
422
|
+
// serving unverified code with no rollback. Write the terminal rollback
|
|
423
|
+
// state — rolling back EVERY deployed worker, since the crash gives no
|
|
424
|
+
// per-worker attribution — BEFORE exiting non-zero, so the workflow's
|
|
425
|
+
// rollback step still fires. This is the terminal-write-before-exit
|
|
426
|
+
// guarantee the former (write-only-on-a-clean-return) shape lacked.
|
|
427
|
+
process.stdout.write(
|
|
428
|
+
`::error::deploy-boot-smoke crashed: ${err?.message ?? err}. ` +
|
|
429
|
+
"Marking every deployed worker for rollback.\n"
|
|
430
|
+
);
|
|
431
|
+
const deployed = uniqueSorted(parseCsv(process.env.DEPLOYED_WORKERS));
|
|
432
|
+
writeRollbackState(failedFile, deployed, {
|
|
433
|
+
githubEnv: process.env.GITHUB_ENV,
|
|
434
|
+
});
|
|
435
|
+
exitCode = 1;
|
|
356
436
|
}
|
|
357
437
|
process.exit(exitCode);
|
|
358
438
|
}
|
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
probeUrl,
|
|
12
12
|
runSmoke,
|
|
13
13
|
resolveFailedFile,
|
|
14
|
+
defaultDeriveSubdomain,
|
|
15
|
+
writeRollbackState,
|
|
14
16
|
} from "./deploy-boot-smoke.mjs";
|
|
15
17
|
|
|
16
18
|
// ---------------------------------------------------------------------------
|
|
@@ -29,26 +31,67 @@ test("parseSmokePaths enforces a leading slash", () => {
|
|
|
29
31
|
});
|
|
30
32
|
|
|
31
33
|
// ---------------------------------------------------------------------------
|
|
32
|
-
// extractSubdomain
|
|
34
|
+
// extractSubdomain — parses the Cloudflare REST subdomain response (M14)
|
|
33
35
|
// ---------------------------------------------------------------------------
|
|
34
36
|
|
|
35
|
-
test("extractSubdomain
|
|
36
|
-
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
assert.equal(extractSubdomain(whoami), "dsj1984");
|
|
37
|
+
test("extractSubdomain reads result.name from the REST subdomain response", () => {
|
|
38
|
+
assert.equal(
|
|
39
|
+
extractSubdomain('{"success":true,"errors":[],"messages":[],"result":{"name":"dsj1984"}}'),
|
|
40
|
+
"dsj1984"
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("extractSubdomain strips a trailing .workers.dev when the API echoes the host", () => {
|
|
45
|
+
assert.equal(extractSubdomain('{"success":true,"result":{"name":"dsj1984.workers.dev"}}'), "dsj1984");
|
|
45
46
|
});
|
|
46
47
|
|
|
47
|
-
test("extractSubdomain returns null
|
|
48
|
-
assert.equal(extractSubdomain("
|
|
48
|
+
test("extractSubdomain returns null for unsuccessful, malformed, or missing bodies", () => {
|
|
49
|
+
assert.equal(extractSubdomain('{"success":false,"result":null}'), null);
|
|
50
|
+
assert.equal(extractSubdomain('{"result":{"name":""}}'), null);
|
|
51
|
+
assert.equal(extractSubdomain('{"result":{"name":42}}'), null);
|
|
52
|
+
assert.equal(extractSubdomain('{"result":{}}'), null);
|
|
53
|
+
assert.equal(extractSubdomain('{"result":"dsj1984"}'), null);
|
|
54
|
+
assert.equal(extractSubdomain("<html>not json</html>"), null);
|
|
49
55
|
assert.equal(extractSubdomain(""), null);
|
|
50
56
|
});
|
|
51
57
|
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// defaultDeriveSubdomain — the REST GET /accounts/{id}/workers/subdomain call
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
test("defaultDeriveSubdomain calls the workers/subdomain endpoint with a bearer token", async () => {
|
|
63
|
+
let seenUrl;
|
|
64
|
+
let seenOptions;
|
|
65
|
+
const fetchImpl = async (url, options) => {
|
|
66
|
+
seenUrl = url;
|
|
67
|
+
seenOptions = options;
|
|
68
|
+
return { ok: true, text: async () => '{"success":true,"result":{"name":"dsj1984"}}' };
|
|
69
|
+
};
|
|
70
|
+
const slug = await defaultDeriveSubdomain({ accountId: "acct-123", apiToken: "tok-abc" }, fetchImpl);
|
|
71
|
+
assert.equal(slug, "dsj1984");
|
|
72
|
+
assert.equal(
|
|
73
|
+
seenUrl,
|
|
74
|
+
"https://api.cloudflare.com/client/v4/accounts/acct-123/workers/subdomain"
|
|
75
|
+
);
|
|
76
|
+
assert.equal(seenOptions.method, "GET");
|
|
77
|
+
assert.equal(seenOptions.headers.Authorization, "Bearer tok-abc");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("defaultDeriveSubdomain returns null without creds, on non-2xx, and on network error", async () => {
|
|
81
|
+
assert.equal(await defaultDeriveSubdomain({ accountId: "", apiToken: "tok" }, async () => ({})), null);
|
|
82
|
+
assert.equal(await defaultDeriveSubdomain({ accountId: "a", apiToken: "" }, async () => ({})), null);
|
|
83
|
+
assert.equal(
|
|
84
|
+
await defaultDeriveSubdomain({ accountId: "a", apiToken: "t" }, async () => ({ ok: false, text: async () => "" })),
|
|
85
|
+
null
|
|
86
|
+
);
|
|
87
|
+
assert.equal(
|
|
88
|
+
await defaultDeriveSubdomain({ accountId: "a", apiToken: "t" }, async () => {
|
|
89
|
+
throw new Error("ECONNRESET");
|
|
90
|
+
}),
|
|
91
|
+
null
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
52
95
|
// ---------------------------------------------------------------------------
|
|
53
96
|
// parseVersionField — the jq/JSON.parse replacement for grep-for-"version"
|
|
54
97
|
// ---------------------------------------------------------------------------
|
|
@@ -315,21 +358,30 @@ test("runSmoke fails without a rollback list when no subdomain is derivable", as
|
|
|
315
358
|
const { log, lines } = collectLogs();
|
|
316
359
|
const result = await runSmoke(
|
|
317
360
|
{ DEPLOYED_WORKERS: "api", SMOKE_PATHS: "/health" },
|
|
318
|
-
{ log,
|
|
361
|
+
{ log, deriveSubdomain: async () => null, probe: async () => ({ status: 200, body: "{}" }) }
|
|
319
362
|
);
|
|
320
363
|
assert.equal(result.exitCode, 1);
|
|
321
364
|
assert.deepEqual(result.failedWorkers, []);
|
|
322
365
|
assert.ok(lines.some((l) => l.includes("Could not derive workers.dev subdomain")));
|
|
323
366
|
});
|
|
324
367
|
|
|
325
|
-
test("runSmoke derives the subdomain
|
|
368
|
+
test("runSmoke derives the subdomain via the REST endpoint when not provided", async () => {
|
|
326
369
|
const { log } = collectLogs();
|
|
327
370
|
const probed = [];
|
|
371
|
+
let seenCreds;
|
|
328
372
|
const result = await runSmoke(
|
|
329
|
-
{
|
|
373
|
+
{
|
|
374
|
+
DEPLOYED_WORKERS: "api",
|
|
375
|
+
SMOKE_PATHS: "/health",
|
|
376
|
+
CLOUDFLARE_ACCOUNT_ID: "acct-123",
|
|
377
|
+
CLOUDFLARE_API_TOKEN: "tok-abc",
|
|
378
|
+
},
|
|
330
379
|
{
|
|
331
380
|
log,
|
|
332
|
-
|
|
381
|
+
deriveSubdomain: async (creds) => {
|
|
382
|
+
seenCreds = creds;
|
|
383
|
+
return "dsj1984";
|
|
384
|
+
},
|
|
333
385
|
probe: async (url) => {
|
|
334
386
|
probed.push(url);
|
|
335
387
|
return { status: 200, body: "{}" };
|
|
@@ -338,6 +390,8 @@ test("runSmoke derives the subdomain from whoami output when not provided", asyn
|
|
|
338
390
|
);
|
|
339
391
|
assert.equal(result.exitCode, 0);
|
|
340
392
|
assert.deepEqual(probed, ["https://api.dsj1984.workers.dev/health"]);
|
|
393
|
+
// The REST creds are threaded through from the environment.
|
|
394
|
+
assert.deepEqual(seenCreds, { accountId: "acct-123", apiToken: "tok-abc" });
|
|
341
395
|
});
|
|
342
396
|
|
|
343
397
|
// ---------------------------------------------------------------------------
|
|
@@ -379,3 +433,70 @@ test("resolveFailedFile creates a private temp dir when SMOKE_FAILED_FILE is uns
|
|
|
379
433
|
assert.equal(path, "/var/folders/xyz/deploy-boot-smoke-Zzz999/smoke-failed-workers.txt");
|
|
380
434
|
assert.notEqual(path, "/tmp/smoke-failed-workers.txt");
|
|
381
435
|
});
|
|
436
|
+
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
// writeRollbackState — the shared terminal writer (crash-path fix, M2)
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
test("writeRollbackState writes the sorted list and the smoke_failed flag", () => {
|
|
442
|
+
const writes = [];
|
|
443
|
+
const appends = [];
|
|
444
|
+
writeRollbackState("/tmp/failed.txt", ["api", "worker-cron"], {
|
|
445
|
+
githubEnv: "/tmp/gh-env",
|
|
446
|
+
writeFile: (path, data) => writes.push({ path, data }),
|
|
447
|
+
appendFile: (path, data) => appends.push({ path, data }),
|
|
448
|
+
});
|
|
449
|
+
assert.deepEqual(writes, [{ path: "/tmp/failed.txt", data: "api\nworker-cron\n" }]);
|
|
450
|
+
assert.deepEqual(appends, [{ path: "/tmp/gh-env", data: "smoke_failed=true\n" }]);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("writeRollbackState is a no-op for an empty worker list", () => {
|
|
454
|
+
let wrote = false;
|
|
455
|
+
let appended = false;
|
|
456
|
+
writeRollbackState("/tmp/failed.txt", [], {
|
|
457
|
+
githubEnv: "/tmp/gh-env",
|
|
458
|
+
writeFile: () => {
|
|
459
|
+
wrote = true;
|
|
460
|
+
},
|
|
461
|
+
appendFile: () => {
|
|
462
|
+
appended = true;
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
assert.equal(wrote, false);
|
|
466
|
+
assert.equal(appended, false);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("writeRollbackState skips the GITHUB_ENV append when githubEnv is unset (still writes the list)", () => {
|
|
470
|
+
const writes = [];
|
|
471
|
+
let appended = false;
|
|
472
|
+
writeRollbackState("/tmp/failed.txt", ["api"], {
|
|
473
|
+
writeFile: (path, data) => writes.push({ path, data }),
|
|
474
|
+
appendFile: () => {
|
|
475
|
+
appended = true;
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
assert.deepEqual(writes, [{ path: "/tmp/failed.txt", data: "api\n" }]);
|
|
479
|
+
assert.equal(appended, false);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
// Crash-path terminal write (M2): runSmoke throwing must still mark every
|
|
484
|
+
// deployed worker for rollback. main() catches and calls writeRollbackState
|
|
485
|
+
// with uniqueSorted(parseCsv(DEPLOYED_WORKERS)); this asserts the exact
|
|
486
|
+
// derivation main() feeds the writer on the crash path.
|
|
487
|
+
// ---------------------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
test("crash-path derives the full deployed-worker rollback set (uniqueSorted + parseCsv)", () => {
|
|
490
|
+
// Mirrors main()'s catch block: on an unhandled error, EVERY deployed
|
|
491
|
+
// worker is marked (no per-worker attribution survives a crash).
|
|
492
|
+
const deployed = uniqueSorted(parseCsv("worker-cron, api ,worker-cron"));
|
|
493
|
+
const writes = [];
|
|
494
|
+
const appends = [];
|
|
495
|
+
writeRollbackState("/tmp/failed.txt", deployed, {
|
|
496
|
+
githubEnv: "/tmp/gh-env",
|
|
497
|
+
writeFile: (path, data) => writes.push({ path, data }),
|
|
498
|
+
appendFile: (path, data) => appends.push({ path, data }),
|
|
499
|
+
});
|
|
500
|
+
assert.deepEqual(writes, [{ path: "/tmp/failed.txt", data: "api\nworker-cron\n" }]);
|
|
501
|
+
assert.deepEqual(appends, [{ path: "/tmp/gh-env", data: "smoke_failed=true\n" }]);
|
|
502
|
+
});
|
|
@@ -68,7 +68,12 @@ import { tmpdir } from "node:os";
|
|
|
68
68
|
import { dirname, join, resolve } from "node:path";
|
|
69
69
|
import { fileURLToPath } from "node:url";
|
|
70
70
|
|
|
71
|
-
import {
|
|
71
|
+
import {
|
|
72
|
+
allConsumersErrored,
|
|
73
|
+
buildReport,
|
|
74
|
+
isFullSha,
|
|
75
|
+
pinDriftTokenProvided,
|
|
76
|
+
} from "./check-pin-drift.mjs";
|
|
72
77
|
import { defaultGhRunner } from "./lib/gh-json.mjs";
|
|
73
78
|
import { parseSemver } from "./lib/semver-duration.mjs";
|
|
74
79
|
|
|
@@ -602,6 +607,13 @@ export function runRepair({
|
|
|
602
607
|
}) {
|
|
603
608
|
// Reuse the detector to classify every consumer (single SSOT for drift).
|
|
604
609
|
const report = buildReport(config, runGh, nowMs);
|
|
610
|
+
// M11: mirror check-pin-drift's dead-credential signal. When EVERY consumer
|
|
611
|
+
// row errored the detector could read no repo at all — the signature of a
|
|
612
|
+
// provided-but-dead PIN_DRIFT_TOKEN (an expired PAT). The caller pairs this
|
|
613
|
+
// with `tokenProvided` to fail the run instead of silently reporting a green
|
|
614
|
+
// "no repairable drift" (which is what an all-error sweep degrades to, since
|
|
615
|
+
// every error row classifies `repairable: false, reason: "error"`).
|
|
616
|
+
const allErrored = allConsumersErrored(report);
|
|
605
617
|
const latestTag = report.latestRelease?.tag ?? null;
|
|
606
618
|
const targetSha = report.latestRelease?.sha ?? null;
|
|
607
619
|
// The pin target is the latest release tag (so the `# <ref>` annotation reads
|
|
@@ -648,7 +660,14 @@ export function runRepair({
|
|
|
648
660
|
}
|
|
649
661
|
}
|
|
650
662
|
|
|
651
|
-
return {
|
|
663
|
+
return {
|
|
664
|
+
ref: effectiveRef,
|
|
665
|
+
targetSha,
|
|
666
|
+
dryRun,
|
|
667
|
+
hasToken: Boolean(token),
|
|
668
|
+
allErrored,
|
|
669
|
+
rows,
|
|
670
|
+
};
|
|
652
671
|
}
|
|
653
672
|
|
|
654
673
|
// ---------------------------------------------------------------------------
|
|
@@ -743,6 +762,23 @@ export function runCli({
|
|
|
743
762
|
}
|
|
744
763
|
}
|
|
745
764
|
}
|
|
765
|
+
|
|
766
|
+
// M11: the repair loop reads consumers with the SAME cross-repo credential the
|
|
767
|
+
// dashboard uses (PIN_DRIFT_TOKEN → GH_TOKEN). When that token was PROVIDED
|
|
768
|
+
// but every detector row errored, the credential is dead (expired PAT) rather
|
|
769
|
+
// than not-yet-provisioned — every consumer degraded to `error` / repairable
|
|
770
|
+
// false, which otherwise renders a reassuring green "no repairable drift". Fail
|
|
771
|
+
// the run loudly so the dead credential is fixed. The absent-token bootstrap
|
|
772
|
+
// (pinDriftTokenProvided false) keeps its exit-0 read-only behavior.
|
|
773
|
+
if (pinDriftTokenProvided(env) && report.allErrored) {
|
|
774
|
+
stderr.write(
|
|
775
|
+
"::error::[platform-repair] PIN_DRIFT_TOKEN was provided but every " +
|
|
776
|
+
"cross-repo consumer read errored — the credential is dead (likely an " +
|
|
777
|
+
"expired fine-grained PAT), not a not-yet-provisioned bootstrap. Rotate " +
|
|
778
|
+
"the token. See docs/runbooks/pin-drift-dashboard.md.\n",
|
|
779
|
+
);
|
|
780
|
+
return 1;
|
|
781
|
+
}
|
|
746
782
|
return 0;
|
|
747
783
|
}
|
|
748
784
|
|
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import assert from "node:assert/strict";
|
|
24
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
26
|
+
import { join } from "node:path";
|
|
24
27
|
import { test } from "node:test";
|
|
25
28
|
|
|
26
29
|
import {
|
|
@@ -32,6 +35,7 @@ import {
|
|
|
32
35
|
parsePrNumberFromUrl,
|
|
33
36
|
renderRepairPrBody,
|
|
34
37
|
renderRepairReport,
|
|
38
|
+
runCli,
|
|
35
39
|
runRepair,
|
|
36
40
|
} from "./platform-repair.mjs";
|
|
37
41
|
|
|
@@ -269,6 +273,12 @@ function makeGh({ consumerWorkflow, npmVersion, openPrs = {}, calls }) {
|
|
|
269
273
|
|
|
270
274
|
const noopGit = () => "";
|
|
271
275
|
|
|
276
|
+
/** A minimal write-sink that records everything written, for stdout/stderr. */
|
|
277
|
+
function capture() {
|
|
278
|
+
const chunks = [];
|
|
279
|
+
return { write: (s) => chunks.push(s), text: () => chunks.join("") };
|
|
280
|
+
}
|
|
281
|
+
|
|
272
282
|
function laggingConfig() {
|
|
273
283
|
return {
|
|
274
284
|
platformRepo: PLATFORM_REPO,
|
|
@@ -456,3 +466,106 @@ test("renderRepairReport tabulates outcomes and a repaired section", () => {
|
|
|
456
466
|
assert.ok(text.includes("Repaired (1)"));
|
|
457
467
|
assert.ok(text.includes("#101"));
|
|
458
468
|
});
|
|
469
|
+
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
// M11 — provided-but-dead read credential vs. not-yet-provisioned bootstrap.
|
|
472
|
+
// The repair loop reads consumers with the SAME cross-repo PAT the dashboard
|
|
473
|
+
// uses (PIN_DRIFT_TOKEN → GH_TOKEN). When that PAT is provided-but-dead every
|
|
474
|
+
// detector row errors → every consumer classifies `error`/repairable-false,
|
|
475
|
+
// which otherwise renders a reassuring green "no repairable drift". runCli must
|
|
476
|
+
// hard-fail on that when the token was provided, and stay exit-0 when it was
|
|
477
|
+
// absent (bootstrap). Mirrors scripts/check-runner-health.mjs error-row
|
|
478
|
+
// handling. (temp/audits/workflow-robustness-review-2026-07-05.md M11)
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* A gh runner where the platform's own release resolution succeeds but EVERY
|
|
483
|
+
* cross-repo consumer read fails non-404 (auth/transport) — the shape of a dead
|
|
484
|
+
* cross-repo PAT. No PR surface is reached because every consumer errors out
|
|
485
|
+
* before repair.
|
|
486
|
+
*/
|
|
487
|
+
function makeAllConsumersFailGh() {
|
|
488
|
+
return (args) => {
|
|
489
|
+
const path = args[1];
|
|
490
|
+
if (args[0] !== "api") {
|
|
491
|
+
throw new Error(`unexpected non-api gh call under dead credential: ${args.join(" ")}`);
|
|
492
|
+
}
|
|
493
|
+
if (path === `repos/${PLATFORM_REPO}/releases/latest`) {
|
|
494
|
+
return JSON.stringify({ tag_name: "mandrel-platform-v1.2.3", published_at: "2020-01-01T00:00:00Z" });
|
|
495
|
+
}
|
|
496
|
+
if (path === `repos/${PLATFORM_REPO}/git/ref/tags/mandrel-platform-v1.2.3`) {
|
|
497
|
+
return JSON.stringify({ object: { sha: LATEST_SHA, type: "commit" } });
|
|
498
|
+
}
|
|
499
|
+
if (/\/contents\/\.github\/workflows/.test(path)) {
|
|
500
|
+
// 403 (not 404) → fail-closed error row, mirroring an expired PAT.
|
|
501
|
+
const err = new Error("gh: Forbidden (HTTP 403)");
|
|
502
|
+
err.stderr = "gh: Forbidden (HTTP 403)\n";
|
|
503
|
+
throw err;
|
|
504
|
+
}
|
|
505
|
+
if (/^repos\/[^/]+\/[^/]+$/.test(path)) {
|
|
506
|
+
return JSON.stringify({ default_branch: "main" });
|
|
507
|
+
}
|
|
508
|
+
throw new Error(`unexpected gh api path: ${path}`);
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function deadCredConfigFile() {
|
|
513
|
+
const dir = mkdtempSync(join(tmpdir(), "platform-repair-dead-"));
|
|
514
|
+
const p = join(dir, "consumers.json");
|
|
515
|
+
writeFileSync(
|
|
516
|
+
p,
|
|
517
|
+
JSON.stringify({
|
|
518
|
+
platformRepo: PLATFORM_REPO,
|
|
519
|
+
consumers: [
|
|
520
|
+
{ name: "domio", repo: "dsj1984/domio" },
|
|
521
|
+
{ name: "athportal", repo: "dsj1984/athportal" },
|
|
522
|
+
],
|
|
523
|
+
}),
|
|
524
|
+
);
|
|
525
|
+
return { dir, p };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
test("runCli: PROVIDED-but-dead PIN_DRIFT_TOKEN (every consumer read errors) exits 1 with ::error::", () => {
|
|
529
|
+
const { dir, p } = deadCredConfigFile();
|
|
530
|
+
const stderr = capture();
|
|
531
|
+
try {
|
|
532
|
+
const code = runCli({
|
|
533
|
+
argv: ["--config", p],
|
|
534
|
+
env: { PIN_DRIFT_TOKEN: "ghp_expired" },
|
|
535
|
+
runGh: makeAllConsumersFailGh(),
|
|
536
|
+
runGit: noopGit,
|
|
537
|
+
runSync: () => {
|
|
538
|
+
throw new Error("must not sync under a dead credential");
|
|
539
|
+
},
|
|
540
|
+
stdout: capture(),
|
|
541
|
+
stderr,
|
|
542
|
+
summaryPath: undefined,
|
|
543
|
+
});
|
|
544
|
+
assert.equal(code, 1);
|
|
545
|
+
assert.match(stderr.text(), /::error::/);
|
|
546
|
+
assert.match(stderr.text(), /credential is dead/);
|
|
547
|
+
} finally {
|
|
548
|
+
rmSync(dir, { recursive: true, force: true });
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test("runCli: ABSENT PIN_DRIFT_TOKEN bootstrap (every consumer read errors, token unset) stays exit 0", () => {
|
|
553
|
+
const { dir, p } = deadCredConfigFile();
|
|
554
|
+
try {
|
|
555
|
+
const code = runCli({
|
|
556
|
+
argv: ["--config", p],
|
|
557
|
+
env: {}, // PIN_DRIFT_TOKEN absent → not-yet-provisioned bootstrap.
|
|
558
|
+
runGh: makeAllConsumersFailGh(),
|
|
559
|
+
runGit: noopGit,
|
|
560
|
+
runSync: () => {
|
|
561
|
+
throw new Error("must not sync during bootstrap");
|
|
562
|
+
},
|
|
563
|
+
stdout: capture(),
|
|
564
|
+
stderr: capture(),
|
|
565
|
+
summaryPath: undefined,
|
|
566
|
+
});
|
|
567
|
+
assert.equal(code, 0);
|
|
568
|
+
} finally {
|
|
569
|
+
rmSync(dir, { recursive: true, force: true });
|
|
570
|
+
}
|
|
571
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Hash-pinned lockfile for the pr-quality SAST (semgrep) step.
|
|
2
|
+
#
|
|
3
|
+
# Target platform : Linux x86_64 / CPython 3.12 (GitHub Actions ubuntu-latest)
|
|
4
|
+
# Tool : semgrep 1.97.0 (+ complete transitive closure)
|
|
5
|
+
# Consumed via : pip install --require-hashes -r scripts/semgrep-requirements.txt
|
|
6
|
+
#
|
|
7
|
+
# Every requirement below is pinned with `==` and carries at least one
|
|
8
|
+
# sha256 hash, as `--require-hashes` demands. The closure was resolved for
|
|
9
|
+
# the linux/cp312 target specifically (via `pip download` with explicit
|
|
10
|
+
# --platform manylinux/musllinux + --python-version 3.12 --abi cp312), NOT
|
|
11
|
+
# from the local interpreter, so the native wheels (semgrep, protobuf,
|
|
12
|
+
# rpds-py, wrapt, charset-normalizer, ruamel.yaml.clib) are the linux x86_64
|
|
13
|
+
# cp312-compatible artifacts.
|
|
14
|
+
#
|
|
15
|
+
# setuptools is intentionally included: the workflow installs it alongside
|
|
16
|
+
# semgrep so opentelemetry's transitive `pkg_resources` import works on
|
|
17
|
+
# Python >=3.12 (which no longer ships setuptools by default).
|
|
18
|
+
#
|
|
19
|
+
# Regenerate with `pip download semgrep==<ver> setuptools --only-binary=:all:
|
|
20
|
+
# --platform manylinux2014_x86_64 --platform manylinux_2_17_x86_64
|
|
21
|
+
# --platform any --python-version 3.12 --implementation cp --abi cp312`.
|
|
22
|
+
|
|
23
|
+
attrs==26.1.0 \
|
|
24
|
+
--hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309
|
|
25
|
+
|
|
26
|
+
boltons==21.0.0 \
|
|
27
|
+
--hash=sha256:b9bb7b58b2b420bbe11a6025fdef6d3e5edc9f76a42fb467afe7ca212ef9948b
|
|
28
|
+
|
|
29
|
+
bracex==3.0 \
|
|
30
|
+
--hash=sha256:3833e61c2f092d5aa0468fa2e6c6e990a306185abf763b6d122f0158e59c58a5
|
|
31
|
+
|
|
32
|
+
certifi==2026.6.17 \
|
|
33
|
+
--hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db
|
|
34
|
+
|
|
35
|
+
charset-normalizer==3.4.7 \
|
|
36
|
+
--hash=sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116
|
|
37
|
+
|
|
38
|
+
click==8.4.2 \
|
|
39
|
+
--hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76
|
|
40
|
+
|
|
41
|
+
click-option-group==0.5.9 \
|
|
42
|
+
--hash=sha256:ad2599248bd373e2e19bec5407967c3eec1d0d4fc4a5e77b08a0481e75991080
|
|
43
|
+
|
|
44
|
+
colorama==0.4.6 \
|
|
45
|
+
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
|
|
46
|
+
|
|
47
|
+
defusedxml==0.7.1 \
|
|
48
|
+
--hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61
|
|
49
|
+
|
|
50
|
+
deprecated==1.3.1 \
|
|
51
|
+
--hash=sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f
|
|
52
|
+
|
|
53
|
+
exceptiongroup==1.2.2 \
|
|
54
|
+
--hash=sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b
|
|
55
|
+
|
|
56
|
+
face==26.0.1 \
|
|
57
|
+
--hash=sha256:ab0a83c37c9789dce658a67a9a80eafaa113c9ec37c5a9d950ff5480542a062d
|
|
58
|
+
|
|
59
|
+
glom==22.1.0 \
|
|
60
|
+
--hash=sha256:5339da206bf3532e01a83a35aca202960ea885156986d190574b779598e9e772
|
|
61
|
+
|
|
62
|
+
googleapis-common-protos==1.75.0 \
|
|
63
|
+
--hash=sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed
|
|
64
|
+
|
|
65
|
+
idna==3.18 \
|
|
66
|
+
--hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2
|
|
67
|
+
|
|
68
|
+
importlib-metadata==7.1.0 \
|
|
69
|
+
--hash=sha256:30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570
|
|
70
|
+
|
|
71
|
+
jsonschema==4.26.0 \
|
|
72
|
+
--hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce
|
|
73
|
+
|
|
74
|
+
jsonschema-specifications==2025.9.1 \
|
|
75
|
+
--hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe
|
|
76
|
+
|
|
77
|
+
markdown-it-py==4.2.0 \
|
|
78
|
+
--hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a
|
|
79
|
+
|
|
80
|
+
mdurl==0.1.2 \
|
|
81
|
+
--hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8
|
|
82
|
+
|
|
83
|
+
opentelemetry-api==1.25.0 \
|
|
84
|
+
--hash=sha256:757fa1aa020a0f8fa139f8959e53dec2051cc26b832e76fa839a6d76ecefd737
|
|
85
|
+
|
|
86
|
+
opentelemetry-exporter-otlp-proto-common==1.25.0 \
|
|
87
|
+
--hash=sha256:15637b7d580c2675f70246563363775b4e6de947871e01d0f4e3881d1848d693
|
|
88
|
+
|
|
89
|
+
opentelemetry-exporter-otlp-proto-http==1.25.0 \
|
|
90
|
+
--hash=sha256:2eca686ee11b27acd28198b3ea5e5863a53d1266b91cda47c839d95d5e0541a6
|
|
91
|
+
|
|
92
|
+
opentelemetry-instrumentation==0.46b0 \
|
|
93
|
+
--hash=sha256:89cd721b9c18c014ca848ccd11181e6b3fd3f6c7669e35d59c48dc527408c18b
|
|
94
|
+
|
|
95
|
+
opentelemetry-instrumentation-requests==0.46b0 \
|
|
96
|
+
--hash=sha256:a8c2472800d8686f3f286cd524b8746b386154092e85a791ba14110d1acc9b81
|
|
97
|
+
|
|
98
|
+
opentelemetry-proto==1.25.0 \
|
|
99
|
+
--hash=sha256:f07e3341c78d835d9b86665903b199893befa5e98866f63d22b00d0b7ca4972f
|
|
100
|
+
|
|
101
|
+
opentelemetry-sdk==1.25.0 \
|
|
102
|
+
--hash=sha256:d97ff7ec4b351692e9d5a15af570c693b8715ad78b8aafbec5c7100fe966b4c9
|
|
103
|
+
|
|
104
|
+
opentelemetry-semantic-conventions==0.46b0 \
|
|
105
|
+
--hash=sha256:6daef4ef9fa51d51855d9f8e0ccd3a1bd59e0e545abe99ac6203804e36ab3e07
|
|
106
|
+
|
|
107
|
+
opentelemetry-util-http==0.46b0 \
|
|
108
|
+
--hash=sha256:8dc1949ce63caef08db84ae977fdc1848fe6dc38e6bbaad0ae3e6ecd0d451629
|
|
109
|
+
|
|
110
|
+
packaging==26.2 \
|
|
111
|
+
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e
|
|
112
|
+
|
|
113
|
+
peewee==3.19.0 \
|
|
114
|
+
--hash=sha256:de220b94766e6008c466e00ce4ba5299b9a832117d9eb36d45d0062f3cfd7417
|
|
115
|
+
|
|
116
|
+
protobuf==4.25.9 \
|
|
117
|
+
--hash=sha256:438c636de8fb706a0de94a12a268ef1ae8f5ba5ae655a7671fcda5968ba3c9be
|
|
118
|
+
|
|
119
|
+
pygments==2.20.0 \
|
|
120
|
+
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
|
|
121
|
+
|
|
122
|
+
referencing==0.37.0 \
|
|
123
|
+
--hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231
|
|
124
|
+
|
|
125
|
+
requests==2.34.2 \
|
|
126
|
+
--hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0
|
|
127
|
+
|
|
128
|
+
rich==13.5.3 \
|
|
129
|
+
--hash=sha256:9257b468badc3d347e146a4faa268ff229039d4c2d176ab0cffb4c4fbc73d5d9
|
|
130
|
+
|
|
131
|
+
rpds-py==2026.6.3 \
|
|
132
|
+
--hash=sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6
|
|
133
|
+
|
|
134
|
+
ruamel.yaml==0.17.40 \
|
|
135
|
+
--hash=sha256:b16b6c3816dff0a93dca12acf5e70afd089fa5acb80604afd1ffa8b465b7722c
|
|
136
|
+
|
|
137
|
+
ruamel.yaml.clib==0.2.15 \
|
|
138
|
+
--hash=sha256:11e5499db1ccbc7f4b41f0565e4f799d863ea720e01d3e99fa0b7b5fcd7802c9
|
|
139
|
+
|
|
140
|
+
semgrep==1.97.0 \
|
|
141
|
+
--hash=sha256:996fe0b2bfac3a4d4511e470fdf5f3bca96b1f794f398e0336c8388802c218de
|
|
142
|
+
|
|
143
|
+
# PINNED to 80.9.0 (the last release that still SHIPS `pkg_resources`):
|
|
144
|
+
# setuptools 81+ removed the vendored `pkg_resources` module, but
|
|
145
|
+
# semgrep 1.97.0's transitive `opentelemetry-instrumentation==0.46b0` imports
|
|
146
|
+
# it at load. A py3.12 venv seeds no setuptools, so the lockfile must supply a
|
|
147
|
+
# pkg_resources-bearing one — 83.0.0 broke the SAST step at runtime with
|
|
148
|
+
# `ModuleNotFoundError: No module named 'pkg_resources'`. Do NOT bump past 80.x
|
|
149
|
+
# without confirming pkg_resources is present (or bumping opentelemetry off it).
|
|
150
|
+
setuptools==80.9.0 \
|
|
151
|
+
--hash=sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922
|
|
152
|
+
|
|
153
|
+
tomli==2.0.2 \
|
|
154
|
+
--hash=sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38
|
|
155
|
+
|
|
156
|
+
typing-extensions==4.16.0 \
|
|
157
|
+
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8
|
|
158
|
+
|
|
159
|
+
urllib3==2.7.0 \
|
|
160
|
+
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
|
|
161
|
+
|
|
162
|
+
wcmatch==8.5.2 \
|
|
163
|
+
--hash=sha256:17d3ad3758f9d0b5b4dedc770b65420d4dac62e680229c287bf24c9db856a478
|
|
164
|
+
|
|
165
|
+
wrapt==1.17.3 \
|
|
166
|
+
--hash=sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828
|
|
167
|
+
|
|
168
|
+
zipp==4.1.0 \
|
|
169
|
+
--hash=sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f
|
|
@@ -51,16 +51,56 @@ on:
|
|
|
51
51
|
permissions:
|
|
52
52
|
contents: read
|
|
53
53
|
|
|
54
|
-
# Serialize staging deploys:
|
|
55
|
-
#
|
|
56
|
-
#
|
|
54
|
+
# Serialize staging deploys: QUEUE, don't cancel (Story #284 / audit H2). An
|
|
55
|
+
# in-flight `wrangler d1 migrations apply` must never be cancelled mid-apply by
|
|
56
|
+
# the next dispatch — a half-applied forward-only migration has no automatic
|
|
57
|
+
# restore. `cancel-in-progress: false` lets a second dispatch wait for the
|
|
58
|
+
# first to finish. (The dispatcher half keeps `cancel-in-progress: true`:
|
|
59
|
+
# cancelling a superseded PENDING dispatch is safe.) The shared
|
|
60
|
+
# deploy-cloudflare.yml additionally serializes per-environment.
|
|
57
61
|
concurrency:
|
|
58
62
|
group: deploy-staging-run
|
|
59
|
-
cancel-in-progress:
|
|
63
|
+
cancel-in-progress: false
|
|
60
64
|
|
|
61
65
|
jobs:
|
|
66
|
+
# M4: sha-drift preflight (Story #284). The CI-green gate is TOCTOU-racy — a
|
|
67
|
+
# commit A goes green and dispatches, but commit B (CI pending, possibly
|
|
68
|
+
# later red) may already be on main, so the deploy would ship B under A's
|
|
69
|
+
# green credential. When `sha` is supplied (dispatcher path), assert the main
|
|
70
|
+
# tip still equals it and FAIL loudly on drift, naming both SHAs. A job with
|
|
71
|
+
# `uses:` cannot carry `steps:`, so this preflight is a SEPARATE preceding
|
|
72
|
+
# job that the `uses:` deploy job `needs:`. Manual dispatches without `sha`
|
|
73
|
+
# skip the assertion (the operator is deploying the current tip on purpose).
|
|
74
|
+
sha-drift-preflight:
|
|
75
|
+
name: Sha-drift preflight (deploy what CI verified)
|
|
76
|
+
runs-on: ubuntu-latest
|
|
77
|
+
timeout-minutes: 5
|
|
78
|
+
steps:
|
|
79
|
+
- name: Checkout main tip
|
|
80
|
+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
81
|
+
with:
|
|
82
|
+
ref: main
|
|
83
|
+
persist-credentials: false
|
|
84
|
+
- name: Assert HEAD matches the CI-verified sha
|
|
85
|
+
env:
|
|
86
|
+
INPUT_SHA: ${{ inputs.sha }}
|
|
87
|
+
shell: bash
|
|
88
|
+
run: |
|
|
89
|
+
set -euo pipefail
|
|
90
|
+
if [ -z "${INPUT_SHA}" ]; then
|
|
91
|
+
echo "No sha input — manual dispatch deploying the current main tip; skipping sha-drift preflight."
|
|
92
|
+
exit 0
|
|
93
|
+
fi
|
|
94
|
+
head="$(git rev-parse HEAD)"
|
|
95
|
+
if [ "${head}" != "${INPUT_SHA}" ]; then
|
|
96
|
+
echo "::error::sha drift: main tip is ${head} but the dispatched (CI-verified) sha is ${INPUT_SHA}. main advanced after CI went green; refusing to deploy an unverified tip." >&2
|
|
97
|
+
exit 1
|
|
98
|
+
fi
|
|
99
|
+
echo "sha-drift preflight OK: main tip ${head} matches the CI-verified sha ${INPUT_SHA}."
|
|
100
|
+
|
|
62
101
|
deploy:
|
|
63
102
|
name: Staging deploy (shared deploy-cloudflare.yml)
|
|
103
|
+
needs: [sha-drift-preflight]
|
|
64
104
|
uses: dsj1984/mandrel-platform/.github/workflows/deploy-cloudflare.yml@<MANDREL_PLATFORM_SHA> # <MANDREL_PLATFORM_TAG>
|
|
65
105
|
with:
|
|
66
106
|
environment: staging
|
|
@@ -71,10 +71,23 @@ jobs:
|
|
|
71
71
|
name: Dispatch staging deploy on CI-green
|
|
72
72
|
runs-on: ubuntu-latest
|
|
73
73
|
timeout-minutes: 5
|
|
74
|
-
# CI-green gate
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
|
|
74
|
+
# CI-green gate — three load-bearing conditions, all required (Story #284):
|
|
75
|
+
# 1. `conclusion == 'success'` — `workflow_run` fires on both success and
|
|
76
|
+
# failure; without this a red main would still deploy.
|
|
77
|
+
# 2. `event == 'push'` — the upstream CI run must itself have been a push
|
|
78
|
+
# to the repo, not a `pull_request` run. `workflow_run.branches:
|
|
79
|
+
# [main]` filters on the *head branch NAME*, so a fork PR whose head
|
|
80
|
+
# branch is literally named `main` with green CI otherwise satisfies
|
|
81
|
+
# the branch filter and the success guard (audit M3).
|
|
82
|
+
# 3. `head_repository.full_name == github.repository` — the CI run must
|
|
83
|
+
# have originated from THIS repo, not a fork. Belt-and-suspenders with
|
|
84
|
+
# condition 2: even a same-name fork branch cannot spoof same-repo
|
|
85
|
+
# provenance, so an external contributor can no longer attacker-time a
|
|
86
|
+
# staging deploy off their fork's green CI.
|
|
87
|
+
if: >-
|
|
88
|
+
${{ github.event.workflow_run.conclusion == 'success'
|
|
89
|
+
&& github.event.workflow_run.event == 'push'
|
|
90
|
+
&& github.event.workflow_run.head_repository.full_name == github.repository }}
|
|
78
91
|
steps:
|
|
79
92
|
- name: Dispatch deploy-staging-run.yml (workflow_dispatch)
|
|
80
93
|
env:
|
|
@@ -84,8 +97,54 @@ jobs:
|
|
|
84
97
|
shell: bash
|
|
85
98
|
run: |
|
|
86
99
|
set -euo pipefail
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
100
|
+
|
|
101
|
+
# L3: the dispatch is otherwise fire-and-forget — a transient GitHub
|
|
102
|
+
# API 5xx fails `gh workflow run`, and the only signal is a red run in
|
|
103
|
+
# a low-visibility dispatcher while that green commit never reaches
|
|
104
|
+
# staging. Retry with backoff, then VERIFY a run was actually created
|
|
105
|
+
# (a 2xx from the dispatch API is not proof a run materialized).
|
|
106
|
+
dispatched=false
|
|
107
|
+
for attempt in 1 2 3; do
|
|
108
|
+
if gh workflow run deploy-staging-run.yml \
|
|
109
|
+
--repo "${REPO}" \
|
|
110
|
+
--ref main \
|
|
111
|
+
-f sha="${SHA}"; then
|
|
112
|
+
dispatched=true
|
|
113
|
+
break
|
|
114
|
+
fi
|
|
115
|
+
echo "::warning::gh workflow run attempt ${attempt} failed; retrying after backoff."
|
|
116
|
+
sleep $((attempt * 5))
|
|
117
|
+
done
|
|
118
|
+
|
|
119
|
+
if [ "${dispatched}" != "true" ]; then
|
|
120
|
+
echo "::error::Failed to dispatch deploy-staging-run.yml after 3 attempts for ${SHA}." >&2
|
|
121
|
+
exit 1
|
|
122
|
+
fi
|
|
123
|
+
|
|
124
|
+
# Verify a runner workflow run was actually created. The dispatch API
|
|
125
|
+
# returns before the run row is queryable, so poll `gh run list` for a
|
|
126
|
+
# recent workflow_dispatch run of the runner workflow. Fail loudly if
|
|
127
|
+
# none appears — a silent non-creation is exactly the failure mode
|
|
128
|
+
# this check exists to surface.
|
|
129
|
+
created=false
|
|
130
|
+
for attempt in 1 2 3 4 5; do
|
|
131
|
+
count="$(gh run list \
|
|
132
|
+
--repo "${REPO}" \
|
|
133
|
+
--workflow deploy-staging-run.yml \
|
|
134
|
+
--event workflow_dispatch \
|
|
135
|
+
--limit 5 \
|
|
136
|
+
--json databaseId --jq 'length' 2>/dev/null || echo 0)"
|
|
137
|
+
if [ "${count:-0}" -gt 0 ]; then
|
|
138
|
+
created=true
|
|
139
|
+
break
|
|
140
|
+
fi
|
|
141
|
+
echo "Run not visible yet (attempt ${attempt}); waiting for the run row to appear."
|
|
142
|
+
sleep $((attempt * 3))
|
|
143
|
+
done
|
|
144
|
+
|
|
145
|
+
if [ "${created}" != "true" ]; then
|
|
146
|
+
echo "::error::Dispatched deploy-staging-run.yml for ${SHA} but no runner workflow run appeared via 'gh run list'." >&2
|
|
147
|
+
exit 1
|
|
148
|
+
fi
|
|
149
|
+
|
|
150
|
+
echo "Dispatched and verified staging deploy for ${SHA} (deploy runs on workflow_dispatch so environment: jobs execute)."
|