@prodgator/report 1.12.0 → 3.5.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.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/dist/cli.js +88 -15
  3. package/package.json +3 -1
package/README.md CHANGED
@@ -8,6 +8,8 @@ Shared report client for the Prodgator GitHub action, GitLab component and Bitbu
8
8
 
9
9
  Options can be set as a CLI flag, a `PRODGATOR_`-prefixed environment variable, or (only with `--pipe`) an unprefixed pipe variable. A flag wins over a pipe variable, which wins over the `PRODGATOR_` variable, which wins over the default.
10
10
 
11
+ On Azure Pipelines, a script step reads `PRODGATOR_URL` and `PRODGATOR_ALLOW_CUSTOM_URL` from pipeline variables, so do not make them settable at queue time. The Marketplace task ignores them and takes inputs only.
12
+
11
13
  | Option | CLI flag | Env (GitLab, any shell) | Pipe variable |
12
14
  |---|---|---|---|
13
15
  | prodgatorUrl | `--prodgator-url` | `PRODGATOR_URL` | `PRODGATOR_URL` |
package/dist/cli.js CHANGED
@@ -7054,6 +7054,8 @@ async function postJson(url, body, audience, deps) {
7054
7054
  method: "POST",
7055
7055
  headers: { authorization: `Bearer ${token}`, "content-type": "application/json", "user-agent": deps.userAgent ?? "prodgator-report/1" },
7056
7056
  body: JSON.stringify(body),
7057
+ // A redirect would carry the token to another address: never follow one.
7058
+ redirect: "manual",
7057
7059
  signal: AbortSignal.timeout(3e4)
7058
7060
  });
7059
7061
  } catch (e) {
@@ -7061,6 +7063,9 @@ async function postJson(url, body, audience, deps) {
7061
7063
  last = new ReportError(0, "NETWORK", e.message, true);
7062
7064
  continue;
7063
7065
  }
7066
+ if (res.type === "opaqueredirect" || res.status >= 300 && res.status < 400) {
7067
+ throw new ReportError(res.status, "REDIRECT_REFUSED", "Prodgator answered with a redirect; the token is never sent to a redirected address. Check prodgator-url.", false);
7068
+ }
7064
7069
  if (res.ok) return await res.clone().json();
7065
7070
  const payload2 = await res.clone().json().catch(() => ({}));
7066
7071
  const retryable = !(deps.fixedToken && payload2.code === "TOKEN_EXPIRED") && (payload2.retryable === true || RETRY_STATUS.has(res.status));
@@ -7076,8 +7081,8 @@ async function postJson(url, body, audience, deps) {
7076
7081
  }
7077
7082
  throw last;
7078
7083
  }
7079
- var sendReport = (baseUrl, provider, req, audience, deps) => postJson(`${baseUrl}/api/ingest/${provider}/reports`, req, audience, deps);
7080
- var completeReport = (baseUrl, provider, reportId, artifactIds, audience, deps) => postJson(`${baseUrl}/api/ingest/${provider}/reports/${encodeURIComponent(reportId)}/complete`, { artifactIds }, audience, deps);
7084
+ var sendReport = (baseUrl, provider, req, audience, deps, azure) => postJson(`${baseUrl}/api/ingest/${provider}/reports`, azure ? { ...req, azure } : req, audience, deps);
7085
+ var completeReport = (baseUrl, provider, reportId, artifactIds, audience, deps, azure) => postJson(`${baseUrl}/api/ingest/${provider}/reports/${encodeURIComponent(reportId)}/complete`, azure ? { artifactIds, azure } : { artifactIds }, audience, deps);
7081
7086
 
7082
7087
  // src/core/upload.ts
7083
7088
  var import_fs4 = require("fs");
@@ -7087,7 +7092,8 @@ function putOnce(slot, file) {
7087
7092
  return new Promise((resolve3, reject) => {
7088
7093
  const url = new URL(slot.url);
7089
7094
  const mod = url.protocol === "http:" ? http : https;
7090
- const req = mod.request(url, { method: "PUT", headers: slot.headers, timeout: 10 * 6e4 }, (res) => {
7095
+ const headers = Object.fromEntries(Object.entries(slot.headers).filter(([k]) => !/^(proxy-)?authorization$|^cookie$/i.test(k)));
7096
+ const req = mod.request(url, { method: "PUT", headers, timeout: 10 * 6e4 }, (res) => {
7091
7097
  let body = "";
7092
7098
  res.setEncoding("utf8");
7093
7099
  res.on("data", (c) => {
@@ -7131,11 +7137,15 @@ async function readGate(baseUrl, gate, deps) {
7131
7137
  res = await (deps.fetch ?? fetch)(`${baseUrl}/api/ingest/bitbucket/gates/${encodeURIComponent(gate.gateId)}`, {
7132
7138
  method: "GET",
7133
7139
  headers: { authorization: `Bearer ${gate.pollToken}`, "user-agent": deps.userAgent ?? "prodgator-report/1" },
7140
+ redirect: "manual",
7134
7141
  signal: AbortSignal.timeout(3e4)
7135
7142
  });
7136
7143
  } catch (e) {
7137
7144
  throw new ReportError(0, "NETWORK", e.message, true);
7138
7145
  }
7146
+ if (res.type === "opaqueredirect" || res.status >= 300 && res.status < 400) {
7147
+ throw new ReportError(res.status, "REDIRECT_REFUSED", "Prodgator answered with a redirect; the token is never sent to a redirected address. Check prodgator-url.", false);
7148
+ }
7139
7149
  if (res.ok) return await res.json();
7140
7150
  const payload2 = await res.json().catch(() => ({}));
7141
7151
  const retryable = res.status === 429 || res.status >= 500;
@@ -7149,7 +7159,42 @@ var CliError = class extends Error {
7149
7159
  this.name = "CliError";
7150
7160
  }
7151
7161
  };
7152
- function detectCi(env, cwd) {
7162
+ var AZURE_TOKEN_HINT = 'Map the job token: add "env: SYSTEM_ACCESSTOKEN: $(System.AccessToken)" to this step, or use the Prodgator task.';
7163
+ function azureContext(env) {
7164
+ const need = (name) => {
7165
+ const v = env[name]?.trim();
7166
+ if (!v) throw new CliError(`${name} is not set; run prodgator-report inside an Azure Pipelines job.`);
7167
+ return v;
7168
+ };
7169
+ const sha = need("BUILD_SOURCEVERSION");
7170
+ if (!/^[0-9a-f]{40}$/i.test(sha)) throw new CliError("BUILD_SOURCEVERSION is not a 40-character commit hash; Prodgator reports need a pipeline that builds a Git commit.");
7171
+ const buildId = Number(need("BUILD_BUILDID"));
7172
+ if (!Number.isInteger(buildId) || buildId < 1) throw new CliError("BUILD_BUILDID is not a build number.");
7173
+ const jobId = need("SYSTEM_JOBID");
7174
+ if (jobId.length > 64) throw new CliError("SYSTEM_JOBID is longer than 64 characters.");
7175
+ const attempt = Number(env.SYSTEM_JOBATTEMPT);
7176
+ return {
7177
+ collectionUri: need("SYSTEM_COLLECTIONURI"),
7178
+ projectId: need("SYSTEM_TEAMPROJECTID"),
7179
+ buildId,
7180
+ jobId,
7181
+ attempt: Number.isInteger(attempt) && attempt >= 1 && attempt <= 999 ? attempt : 1,
7182
+ sha: sha.toLowerCase()
7183
+ };
7184
+ }
7185
+ function detectCi(env, cwd, tokenVariable) {
7186
+ if (env.TF_BUILD === "True") {
7187
+ const variable = tokenVariable ?? "SYSTEM_ACCESSTOKEN";
7188
+ if (!env[variable]?.trim()) throw new CliError(variable === "SYSTEM_ACCESSTOKEN" ? AZURE_TOKEN_HINT : `No job token in ${variable}. ${AZURE_TOKEN_HINT}`);
7189
+ return {
7190
+ provider: "azure",
7191
+ workspace: env.BUILD_SOURCESDIRECTORY || cwd,
7192
+ jobName: env.SYSTEM_JOBDISPLAYNAME || "job",
7193
+ runner: env.AGENT_NAME || null,
7194
+ defaultTokenVariable: "SYSTEM_ACCESSTOKEN",
7195
+ azure: azureContext(env)
7196
+ };
7197
+ }
7153
7198
  if (env.GITLAB_CI === "true") {
7154
7199
  return {
7155
7200
  provider: "gitlab",
@@ -7170,13 +7215,14 @@ function detectCi(env, cwd) {
7170
7215
  };
7171
7216
  }
7172
7217
  if (env.GITHUB_ACTIONS === "true") throw new CliError("On GitHub Actions use the prodgator/prodgator-action action instead of prodgator-report.");
7173
- throw new CliError("prodgator-report runs in GitLab CI or Bitbucket Pipelines (GITLAB_CI or BITBUCKET_BUILD_NUMBER is not set).");
7218
+ throw new CliError("prodgator-report runs in GitLab CI, Bitbucket Pipelines or Azure Pipelines (GITLAB_CI, BITBUCKET_BUILD_NUMBER or TF_BUILD is not set).");
7174
7219
  }
7175
7220
 
7176
7221
  // src/cli/options.ts
7177
7222
  var DEFAULT_PRODGATOR_URL = "https://api.prodgator.io";
7178
7223
  var OPTION_NAMES = {
7179
7224
  prodgatorUrl: { flag: "--prodgator-url", env: "PRODGATOR_URL", pipe: "PRODGATOR_URL" },
7225
+ allowCustomUrl: { flag: "--allow-custom-url", env: "PRODGATOR_ALLOW_CUSTOM_URL", pipe: null },
7180
7226
  audience: { flag: "--audience", env: "PRODGATOR_AUDIENCE", pipe: "AUDIENCE" },
7181
7227
  summary: { flag: "--summary", env: "PRODGATOR_SUMMARY", pipe: "SUMMARY" },
7182
7228
  summaryFile: { flag: "--summary-file", env: "PRODGATOR_SUMMARY_FILE", pipe: "SUMMARY_FILE" },
@@ -7222,8 +7268,8 @@ function parseOptions(argv, env) {
7222
7268
  pipe = true;
7223
7269
  continue;
7224
7270
  }
7225
- if (a === "--gate" && (argv[i + 1] === void 0 || argv[i + 1].startsWith("--"))) {
7226
- flags.set("--gate", "true");
7271
+ if ((a === "--gate" || a === "--allow-custom-url") && (argv[i + 1] === void 0 || argv[i + 1].startsWith("--"))) {
7272
+ flags.set(a, "true");
7227
7273
  continue;
7228
7274
  }
7229
7275
  const eq = a.indexOf("=");
@@ -7266,6 +7312,7 @@ function parseOptions(argv, env) {
7266
7312
  attestations: get("attestations"),
7267
7313
  redactVariables: list(get("redactVariables")),
7268
7314
  failOnError: fail === "true",
7315
+ allowCustomUrl: (get("allowCustomUrl") ?? "false").trim().toLowerCase() === "true",
7269
7316
  tokenVariable: get("tokenVariable")?.trim() ?? null,
7270
7317
  pipe,
7271
7318
  gate: (get("gate") ?? "false").trim().toLowerCase() === "true",
@@ -7276,11 +7323,12 @@ function parseOptions(argv, env) {
7276
7323
  // src/cli/token.ts
7277
7324
  var HOW = {
7278
7325
  gitlab: (v) => `Add "id_tokens: ${v}: aud: <Prodgator API origin>" to the job (the Prodgator component does this).`,
7326
+ azure: () => AZURE_TOKEN_HINT,
7279
7327
  bitbucket: () => 'Add "oidc: audiences: [<Prodgator API origin>]" to the step that runs the pipe.'
7280
7328
  };
7281
7329
  function readToken(env, variable, provider) {
7282
7330
  const t = env[variable]?.trim();
7283
- if (!t) throw new CliError(`No OIDC token in ${variable}. ${HOW[provider](variable)}`);
7331
+ if (!t) throw new CliError(`No ${provider === "azure" ? "job" : "OIDC"} token in ${variable}. ${HOW[provider](variable)}`);
7284
7332
  return t;
7285
7333
  }
7286
7334
  function payload(token) {
@@ -7293,6 +7341,7 @@ function payload(token) {
7293
7341
  }
7294
7342
  }
7295
7343
  function tokenProblem(token, audience, provider, variable, nowMs) {
7344
+ if (provider === "azure") return token ? null : "The job token is empty.";
7296
7345
  const p = payload(token);
7297
7346
  if (!p) return null;
7298
7347
  const auds = [].concat(p.aud ?? []).filter((a) => typeof a === "string");
@@ -7305,10 +7354,30 @@ function tokenProblem(token, audience, provider, variable, nowMs) {
7305
7354
  return null;
7306
7355
  }
7307
7356
 
7357
+ // src/cli/url-policy.ts
7358
+ var PRODGATOR_DOMAINS = ["prodgator.io", "prodgator.dev"];
7359
+ var LOCAL_HOSTS = ["localhost", "127.0.0.1"];
7360
+ var isProdgatorHost = (host) => PRODGATOR_DOMAINS.some((d) => host === d || host.endsWith(`.${d}`));
7361
+ function checkTokenDestination(prodgatorUrl, provider, allowCustomUrl) {
7362
+ if (provider !== "azure") return null;
7363
+ const u = new URL(prodgatorUrl);
7364
+ const optIn = "To send the job token to another server on purpose, set allow-custom-url (--allow-custom-url, PRODGATOR_ALLOW_CUSTOM_URL=true, or the task input allowCustomUrl).";
7365
+ if (u.username || u.password) throw new CliError(`prodgator-url must not carry a user name or password (host ${u.hostname}).`);
7366
+ if (u.protocol === "https:" && !u.port && isProdgatorHost(u.hostname)) return null;
7367
+ if (!allowCustomUrl) {
7368
+ throw new CliError(`Refusing to send the Azure Pipelines job token to ${u.host}: it is not prodgator.io or prodgator.dev over https. ${optIn}`);
7369
+ }
7370
+ if (u.protocol === "http:" && !LOCAL_HOSTS.includes(u.hostname)) {
7371
+ throw new CliError(`Refusing to send the Azure Pipelines job token to ${u.host} over plain http; only localhost and 127.0.0.1 may use http.`);
7372
+ }
7373
+ return `Sending the Azure Pipelines job token to ${u.host}, which is not a Prodgator address. allow-custom-url is on.`;
7374
+ }
7375
+
7308
7376
  // src/cli/redact.ts
7309
7377
  var ALWAYS_REDACT = Object.freeze([
7310
7378
  "PRODGATOR_ID_TOKEN",
7311
7379
  "BITBUCKET_STEP_OIDC_TOKEN",
7380
+ "SYSTEM_ACCESSTOKEN",
7312
7381
  "CI_JOB_TOKEN",
7313
7382
  "CI_JOB_JWT",
7314
7383
  "CI_JOB_JWT_V1",
@@ -7377,7 +7446,7 @@ function decided(view) {
7377
7446
  return `${by}${note}`;
7378
7447
  }
7379
7448
  async function runGate(ci, opts, client, deps) {
7380
- if (ci.provider !== "bitbucket") throw new CliError("Gate mode runs in Bitbucket Pipelines only. On GitLab, use protected environment approvals.");
7449
+ if (ci.provider !== "bitbucket") throw new CliError("Gate mode runs in Bitbucket Pipelines only. On GitLab, use protected environment approvals. On Azure Pipelines, use environment approvals and checks.");
7381
7450
  const { log } = deps;
7382
7451
  const deadline = deps.nowMs() + opts.timeoutMinutes * 6e4;
7383
7452
  const gate = await openGate(opts.prodgatorUrl, opts.audience, { ...client, maxAttempts: 10 });
@@ -7451,7 +7520,7 @@ async function run(deps = {}) {
7451
7520
  const argv = deps.argv ?? process.argv.slice(2);
7452
7521
  const log = deps.log ?? { info: (m) => console.log(m), warn: (m) => console.log(`Warning: ${m}`), error: (m) => console.error(m) };
7453
7522
  if (argv.includes("--version")) {
7454
- log.info("1.12.0");
7523
+ log.info("3.5.0");
7455
7524
  return 0;
7456
7525
  }
7457
7526
  let failOnError = false;
@@ -7461,13 +7530,15 @@ async function run(deps = {}) {
7461
7530
  const opts = parseOptions(argv, env);
7462
7531
  failOnError = opts.failOnError;
7463
7532
  gate = opts.gate;
7464
- const ci = detectCi(env, deps.cwd ?? process.cwd());
7533
+ const ci = detectCi(env, deps.cwd ?? process.cwd(), opts.tokenVariable ?? void 0);
7465
7534
  const variable = opts.tokenVariable ?? ci.defaultTokenVariable;
7535
+ const custom = checkTokenDestination(opts.prodgatorUrl, ci.provider, opts.allowCustomUrl);
7536
+ if (custom) log.warn(custom);
7466
7537
  const token = readToken(env, variable, ci.provider);
7467
7538
  const problem = tokenProblem(token, opts.audience, ci.provider, variable, (deps.nowMs ?? Date.now)());
7468
7539
  if (problem) throw new CliError(problem);
7469
7540
  if (opts.gate) {
7470
- const client2 = { getToken: async () => token, fetch: deps.fetch, sleep: deps.sleep, fixedToken: true, userAgent: `prodgator-report/${"1.12.0"} (${ci.provider}; gate)` };
7541
+ const client2 = { getToken: async () => token, fetch: deps.fetch, sleep: deps.sleep, fixedToken: true, userAgent: `prodgator-report/${"3.5.0"} (${ci.provider}; gate)` };
7471
7542
  return await runGate(ci, { prodgatorUrl: opts.prodgatorUrl, audience: opts.audience, timeoutMinutes: opts.gateTimeoutMinutes }, client2, {
7472
7543
  sleep: deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
7473
7544
  nowMs: deps.nowMs ?? Date.now,
@@ -7490,10 +7561,10 @@ async function run(deps = {}) {
7490
7561
  fetch: deps.fetch,
7491
7562
  sleep: deps.sleep,
7492
7563
  fixedToken: true,
7493
- userAgent: `prodgator-report/${"1.12.0"} (${ci.provider})`
7564
+ userAgent: `prodgator-report/${"3.5.0"} (${ci.provider})`
7494
7565
  };
7495
7566
  const request = buildIngestRequest({ job: { name: opts.jobName ?? ci.jobName, matrix: opts.matrix, runner: ci.runner }, name: opts.name, summary, attestations, files });
7496
- const res = await sendReport(opts.prodgatorUrl, ci.provider, request, opts.audience, client);
7567
+ const res = await sendReport(opts.prodgatorUrl, ci.provider, request, opts.audience, client, ci.azure);
7497
7568
  for (const w of res.warnings) log.warn(WARNINGS[w] ?? w);
7498
7569
  for (const s of res.skipped) if (s.reason !== "already_uploaded") log.warn(`${s.path} was not uploaded: ${SKIPPED[s.reason] ?? s.reason}`);
7499
7570
  if (!res.trusted) log.info("Prodgator stored this report as coming from an untrusted source (fork merge request or external pull request); release policies ignore it by default.");
@@ -7509,10 +7580,11 @@ async function run(deps = {}) {
7509
7580
  }
7510
7581
  }
7511
7582
  if (uploaded.length) {
7512
- const done = await completeReport(opts.prodgatorUrl, ci.provider, res.reportId, uploaded, opts.audience, client);
7583
+ const done = await completeReport(opts.prodgatorUrl, ci.provider, res.reportId, uploaded, opts.audience, client, ci.azure);
7513
7584
  for (const r of done.results.filter((r2) => r2.status !== "uploaded")) log.warn(`artifact ${r.artifactId} was ${r.status}${r.reason ? `: ${r.reason}` : ""}`);
7514
7585
  }
7515
7586
  log.info(`Prodgator report: ${res.url}`);
7587
+ deps.onOutcome?.("sent");
7516
7588
  return 0;
7517
7589
  } catch (e) {
7518
7590
  const msg2 = e instanceof ReportError ? `${e.code}: ${e.message}` : e.message;
@@ -7525,6 +7597,7 @@ async function run(deps = {}) {
7525
7597
  return 1;
7526
7598
  }
7527
7599
  log.warn(`Prodgator report was not sent: ${msg2}`);
7600
+ deps.onOutcome?.("not_sent");
7528
7601
  return 0;
7529
7602
  }
7530
7603
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prodgator/report",
3
- "version": "1.12.0",
3
+ "version": "3.5.0",
4
4
  "description": "Send CI job summaries, test results, attestations and artifacts to Prodgator, authenticated with the CI system's OIDC token",
5
5
  "license": "MIT",
6
6
  "homepage": "https://docs.prodgator.io/pipelines/run-reports",
@@ -20,6 +20,7 @@
20
20
  "build": "node scripts/build.mjs",
21
21
  "typecheck": "tsc --noEmit",
22
22
  "test": "vitest run",
23
+ "build:azure-task": "node scripts/build-azure-task.mjs",
23
24
  "release:prepare": "node scripts/prepare-release.mjs"
24
25
  },
25
26
  "dependencies": {
@@ -28,6 +29,7 @@
28
29
  },
29
30
  "devDependencies": {
30
31
  "@types/node": "^22.20.5",
32
+ "azure-pipelines-task-lib": "^5.281.0",
31
33
  "esbuild": "^0.25.0",
32
34
  "typescript": "^5.9.3",
33
35
  "vitest": "^4.1.11",