From 49333579b0e60c1f439d4ecacf33df9caa748f70 Mon Sep 17 00:00:00 2001
From: Ian Johnson <ian@ianjohnson.dev>
Date: Fri, 3 Feb 2023 22:09:27 -0500
Subject: [PATCH] feat: support the no_proxy environment variable

Closes #563

BREAKING CHANGE: The `no_proxy` and `NO_PROXY` environment variables are
now respected when obtaining proxy configuration from the environment,
bypassing the proxy when they match the GitHub host. This does not apply
when [`proxy`](https://github.com/semantic-release/github#proxy) is
explicitly provided in the plugin configuration.
---
 lib/resolve-config.js      | 41 ++++++++++++++++++---------------
 lib/resolve-proxy.js       | 24 +++++++++++++++++++
 test/resolve-proxy.test.js | 47 ++++++++++++++++++++++++++++++++++++++
 3 files changed, 94 insertions(+), 18 deletions(-)
 create mode 100644 lib/resolve-proxy.js
 create mode 100644 test/resolve-proxy.test.js

diff --git a/lib/resolve-config.js b/lib/resolve-config.js
index 91b85a84..eed1928c 100644
--- a/lib/resolve-config.js
+++ b/lib/resolve-config.js
@@ -1,4 +1,5 @@
 const {isNil, castArray} = require('lodash');
+const resolveProxy = require('./resolve-proxy');
 
 module.exports = (
   {
@@ -15,21 +16,25 @@ module.exports = (
     addReleases,
   },
   {env}
-) => ({
-  githubToken: env.GH_TOKEN || env.GITHUB_TOKEN,
-  githubUrl: githubUrl || env.GITHUB_API_URL || env.GH_URL || env.GITHUB_URL,
-  githubApiPathPrefix: githubApiPathPrefix || env.GH_PREFIX || env.GITHUB_PREFIX || '',
-  proxy: isNil(proxy) ? env.http_proxy || env.HTTP_PROXY || false : proxy,
-  assets: assets ? castArray(assets) : assets,
-  successComment,
-  failTitle: isNil(failTitle) ? 'The automated release is failing 🚨' : failTitle,
-  failComment,
-  labels: isNil(labels) ? ['semantic-release'] : labels === false ? false : castArray(labels),
-  assignees: assignees ? castArray(assignees) : assignees,
-  releasedLabels: isNil(releasedLabels)
-    ? [`released<%= nextRelease.channel ? \` on @\${nextRelease.channel}\` : "" %>`]
-    : releasedLabels === false
-    ? false
-    : castArray(releasedLabels),
-  addReleases: isNil(addReleases) ? false : addReleases,
-});
+) => {
+  githubUrl ||= env.GITHUB_API_URL || env.GH_URL || env.GITHUB_URL;
+
+  return {
+    githubToken: env.GH_TOKEN || env.GITHUB_TOKEN,
+    githubUrl,
+    githubApiPathPrefix: githubApiPathPrefix || env.GH_PREFIX || env.GITHUB_PREFIX || '',
+    proxy: isNil(proxy) ? resolveProxy(githubUrl, env) : proxy,
+    assets: assets ? castArray(assets) : assets,
+    successComment,
+    failTitle: isNil(failTitle) ? 'The automated release is failing 🚨' : failTitle,
+    failComment,
+    labels: isNil(labels) ? ['semantic-release'] : labels === false ? false : castArray(labels),
+    assignees: assignees ? castArray(assignees) : assignees,
+    releasedLabels: isNil(releasedLabels)
+      ? [`released<%= nextRelease.channel ? \` on @\${nextRelease.channel}\` : "" %>`]
+      : releasedLabels === false
+      ? false
+      : castArray(releasedLabels),
+    addReleases: isNil(addReleases) ? false : addReleases,
+  };
+};
diff --git a/lib/resolve-proxy.js b/lib/resolve-proxy.js
new file mode 100644
index 00000000..25c05ab3
--- /dev/null
+++ b/lib/resolve-proxy.js
@@ -0,0 +1,24 @@
+module.exports = (githubUrl, env) => {
+  githubUrl ||= 'https://api.github.com';
+  const proxy = env.http_proxy || env.HTTP_PROXY || false;
+  const noProxy = env.no_proxy || env.NO_PROXY;
+
+  if (proxy && noProxy) {
+    const {hostname} = new URL(githubUrl);
+    for (let noProxyHost of noProxy.split(',')) {
+      if (noProxyHost === '*') {
+        return false;
+      }
+
+      if (noProxyHost.startsWith('.')) {
+        noProxyHost = noProxyHost.slice(1);
+      }
+
+      if (hostname === noProxyHost || hostname.endsWith('.' + noProxyHost)) {
+        return false;
+      }
+    }
+  }
+
+  return proxy;
+};
diff --git a/test/resolve-proxy.test.js b/test/resolve-proxy.test.js
new file mode 100644
index 00000000..bc6aacf8
--- /dev/null
+++ b/test/resolve-proxy.test.js
@@ -0,0 +1,47 @@
+const test = require('ava');
+const resolveProxy = require('../lib/resolve-proxy');
+
+test('Resolve proxy with no proxy configuration', (t) => {
+  t.is(resolveProxy(undefined, {}), false);
+});
+
+test('Resolve proxy with no exclusions', (t) => {
+  t.is(resolveProxy(undefined, {http_proxy: 'proxy.example.com'}), 'proxy.example.com');
+});
+
+test('Resolve proxy with no matching exclusion', (t) => {
+  t.is(
+    resolveProxy(undefined, {
+      http_proxy: 'proxy.example.com',
+      no_proxy: 'notapi.github.com,.example.org,example.net',
+    }),
+    'proxy.example.com'
+  );
+});
+
+test('Resolve proxy with matching exclusion', (t) => {
+  t.is(resolveProxy(undefined, {http_proxy: 'proxy.example.com', no_proxy: 'github.com'}), false);
+});
+
+test('Resolve proxy with matching exclusion (leading .)', (t) => {
+  t.is(resolveProxy(undefined, {http_proxy: 'proxy.example.com', no_proxy: '.github.com'}), false);
+});
+
+test('Resolve proxy with global exclusion', (t) => {
+  t.is(resolveProxy(undefined, {http_proxy: 'proxy.example.com', no_proxy: '*'}), false);
+});
+
+test('Resolve proxy with matching GitHub Enterprise exclusion', (t) => {
+  t.is(
+    resolveProxy('https://github.example.com/api/v3', {http_proxy: 'proxy.example.com', no_proxy: 'example.com'}),
+    false
+  );
+});
+
+test('Resolve proxy with uppercase environment variables', (t) => {
+  t.is(resolveProxy(undefined, {HTTP_PROXY: 'proxy.example.com', NO_PROXY: 'github.com'}), false);
+  t.is(
+    resolveProxy(undefined, {HTTP_PROXY: 'proxy.example.com', NO_PROXY: 'subdomain.github.com'}),
+    'proxy.example.com'
+  );
+});