Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for artifacts uploaded by actions/upload-artifact@v4 #1791

Merged
merged 10 commits into from
Mar 9, 2024
11 changes: 7 additions & 4 deletions .github/workflows/maven-build.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: CI

on:
on:
push:
branches:
- main
Expand Down Expand Up @@ -66,6 +66,9 @@ jobs:
os: [ ubuntu, windows ]
java: [ 11, 17 ]
steps:
- name: Support longpaths on Windows
if: ${{ matrix.os == 'windows' }}
run: git config --global core.longpaths true
- uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
Expand All @@ -84,15 +87,15 @@ jobs:
env:
MAVEN_OPTS: ${{ env.JAVA_11_PLUS_MAVEN_OPTS }}
run: mvn -B clean install -D enable-ci --file pom.xml "-Dsurefire.argLine=--add-opens java.base/java.net=ALL-UNNAMED"
- name: Codecov Report
- name: Codecov Report
if: matrix.os == 'ubuntu' && matrix.java == '17'
uses: codecov/codecov-action@v3.1.4

test-java-8:
name: test Java 8 (no-build)
needs: build
runs-on: ubuntu-latest
steps:
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with:
Expand All @@ -103,6 +106,6 @@ jobs:
with:
java-version: 8
distribution: 'temurin'
cache: 'maven'
cache: 'maven'
- name: Maven Test (no build) Java 8
run: mvn -B surefire:test -DfailIfNoTests -Dsurefire.excludesFile=src/test/resources/slow-or-flaky-tests.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@
import java.io.IOException;
import java.io.InputStream;
import java.io.InterruptedIOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpClient;
import java.net.http.HttpHeaders;
import java.net.http.HttpRequest;
import java.net.http.HttpRequest.BodyPublisher;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import javax.annotation.CheckForNull;
import javax.annotation.Nonnull;
Expand All @@ -22,16 +26,30 @@
* {@link GitHubConnector} for {@link HttpClient}.
*
* @author Liam Newman
* @author Guillaume Smet
*/
public class HttpClientGitHubConnector implements GitHubConnector {

private static final String AUTHORIZATION_HEADER = "Authorization";
private static final int MAX_REDIRECTS = 5;

private final HttpClient client;

/**
* Instantiates a new HttpClientGitHubConnector with a default HttpClient.
*/
public HttpClientGitHubConnector() {
this(HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build());
// We handle redirects manually as Java is copying all the headers when redirected
// even when redirecting to a different host which is problematic as we don't want
// to push the Authorization header when redirected to a different host.
// This problem was discovered when upload-artifact@v4 was released as the new
// service we are redirected to for downloading the artifacts doesn't support
// having the Authorization header set.
// The new implementation does not push the Authorization header when redirected
// to a different host, which is similar to what Okhttp is doing:
// https://github.com/square/okhttp/blob/f9dfd4e8cc070ca2875a67d8f7ad939d95e7e296/okhttp/src/main/kotlin/okhttp3/internal/http/RetryAndFollowUpInterceptor.kt#L313-L318
// See also https://github.com/arduino/report-size-deltas/pull/83 for more context
this(HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build());
bitwiseman marked this conversation as resolved.
Show resolved Hide resolved
}

/**
Expand Down Expand Up @@ -69,13 +87,50 @@ public GitHubConnectorResponse send(GitHubConnectorRequest connectorRequest) thr
HttpRequest request = builder.build();

try {
HttpResponse<InputStream> httpResponse = client.send(request, HttpResponse.BodyHandlers.ofInputStream());
HttpResponse<InputStream> httpResponse = send(request, 0);
return new HttpClientGitHubConnectorResponse(connectorRequest, httpResponse);
} catch (InterruptedException e) {
throw (InterruptedIOException) new InterruptedIOException(e.getMessage()).initCause(e);
}
}

private HttpResponse<InputStream> send(HttpRequest request, int redirects)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I see what you're trying to do here. Looks good!

But I'd like moved to GitHubClient.detectRedirect.

From what you described, authorization stripping is the behavior GitHub expects. That means, regardless of what GitHubConnector implementation is used, when the connector returns a redirect response this project must strip the Authorization header as appropriate and then follow the redirect. So, this functionality should go in GitHubClient instead of HttpClientGitHubConnector.

On the plus side, GitHubClient already has functionality for retries.

connectorResponse = connector.send(connectorRequest);
logResponse(connectorResponse);
noteRateLimit(request.rateLimitTarget(), connectorResponse);
detectKnownErrors(connectorResponse, request, handler != null);
logResponseBody(connectorResponse);
return createResponse(connectorResponse, handler);
} catch (RetryRequestException e) {
// retry requested by requested by error handler (rate limit handler for example)
if (retries > 0 && e.connectorRequest != null) {
connectorRequest = e.connectorRequest;
}

A retry exception can be thrown during error detection and the updated request contained in the exception will be used for the next attempt. We use this for token refresh, now we can use it for redirects. And we already detect redirects.

private void detectExpiredToken(GitHubConnectorResponse connectorResponse, GitHubRequest request)
throws IOException {
if (connectorResponse.statusCode() != HTTP_UNAUTHORIZED) {
return;
}
String originalAuthorization = connectorResponse.request().header("Authorization");
if (Objects.isNull(originalAuthorization) || originalAuthorization.isEmpty()) {
return;
}
GitHubConnectorRequest updatedRequest = prepareConnectorRequest(request);
String updatedAuthorization = updatedRequest.header("Authorization");
if (!originalAuthorization.equals(updatedAuthorization)) {
throw new RetryRequestException(updatedRequest);
}
}
private void detectRedirect(GitHubConnectorResponse connectorResponse) throws IOException {
if (connectorResponse.statusCode() == HTTP_MOVED_PERM || connectorResponse.statusCode() == HTTP_MOVED_TEMP) {
// GitHubClient depends on GitHubConnector implementations to follow any redirects automatically
// If this is not done and a redirect is requested, throw in order to maintain security and consistency
throw new HttpException(
"GitHubConnnector did not automatically follow redirect.\n"
+ "Change your http client configuration to automatically follow redirects as appropriate.",
connectorResponse.statusCode(),
"Redirect",
connectorResponse.request().url().toString());
}
}

Use detectExpiredToken as the template and replace detectRedirect with the logic you've implemented here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dunno both if it's worth it and if it's safe enough.
As for the former, as I said, I did my homework and all the other clients are having this behavior of stripping the Authorization header. So sure, we could add another client that wouldn't have this behavior at some point in the future but I also think it's probably better to let the client handle the redirects if it can do it properly.

I also wanted to be very safe and carefully implement the rule of the Java HttpClient to make sure I wouldn't break anything.

Anyway, maybe you're right, I'm just not sure it's a decision that is so obvious.

throws IOException, InterruptedException {
HttpResponse<InputStream> response = client.send(request, HttpResponse.BodyHandlers.ofInputStream());

if (!isRedirecting(response.statusCode()) || redirects > MAX_REDIRECTS) {
return response;
}

URI redirectedUri = getRedirectedUri(request.uri(), response.headers());
boolean sameHost = redirectedUri.getHost().equalsIgnoreCase(request.uri().getHost());

// mimicking the behavior of Redirect#NORMAL which was the behavior we used before
if (!request.uri().getScheme().equalsIgnoreCase(redirectedUri.getScheme())
&& !"https".equalsIgnoreCase(redirectedUri.getScheme())) {
return response;
}

String redirectedMethod = getRedirectedMethod(response.statusCode(), request.method());

// let's build the new redirected request
HttpRequest.Builder newRequestBuilder = HttpRequest.newBuilder();
newRequestBuilder.uri(redirectedUri);
newRequestBuilder.method(redirectedMethod,
getRedirectedPublisher(request, response.statusCode(), redirectedMethod));
for (Entry<String, List<String>> headerEntry : request.headers().map().entrySet()) {
// if we redirect to a different host, we don't copy the Authorization header
if (!sameHost && headerEntry.getKey().equalsIgnoreCase(AUTHORIZATION_HEADER)) {
continue;
}
for (String value : headerEntry.getValue()) {
newRequestBuilder.header(headerEntry.getKey(), value);
}
}

return send(newRequestBuilder.build(), redirects + 1);
}

/**
* Initial response information when a response is initially received and before the body is processed.
*
Expand Down Expand Up @@ -104,4 +159,49 @@ public void close() throws IOException {
IOUtils.closeQuietly(response.body());
}
}

private static URI getRedirectedUri(URI originalUri, HttpHeaders headers) throws IOException {
URI redirectedURI;
redirectedURI = headers.firstValue("Location")
.map(URI::create)
.orElseThrow(() -> new IOException("Invalid redirection"));

// redirect could be relative to original URL, but if not
// then redirect is used.
redirectedURI = originalUri.resolve(redirectedURI);
return redirectedURI;
}

// This implements the exact same rules as the ones applied in RedirectFilter
bitwiseman marked this conversation as resolved.
Show resolved Hide resolved
private static BodyPublisher getRedirectedPublisher(HttpRequest originalRequest,
int originalStatusCode,
String newMethod) {
if (originalStatusCode == 303 || originalRequest.method().equalsIgnoreCase(newMethod)
|| !originalRequest.bodyPublisher().isPresent()) {
return HttpRequest.BodyPublishers.noBody();
}

return originalRequest.bodyPublisher().get();
}

// This implements the exact same rules as the ones applied in RedirectFilter
private static boolean isRedirecting(int statusCode) {
return statusCode == 301 || statusCode == 302 || statusCode == 303 || statusCode == 307 || statusCode == 308;
}

// This implements the exact same rules as the ones applied in RedirectFilter
private static String getRedirectedMethod(int statusCode, String originalMethod) {
switch (statusCode) {
case 301 :
case 302 :
return originalMethod.equals("POST") ? "GET" : originalMethod;
case 303 :
return "GET";
case 307 :
case 308 :
return originalMethod;
default :
return originalMethod;
}
}
}
22 changes: 20 additions & 2 deletions src/test/java/org/kohsuke/github/GHWorkflowRunTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ public void testArtifacts() throws IOException {
checkArtifactProperties(artifacts.get(0), "artifact1");
checkArtifactProperties(artifacts.get(1), "artifact2");

// Test download
// Test download from upload-artifact@v3 infrastructure
String artifactContent = artifacts.get(0).download((is) -> {
try (ZipInputStream zis = new ZipInputStream(is)) {
StringBuilder sb = new StringBuilder();
Expand All @@ -329,7 +329,25 @@ public void testArtifacts() throws IOException {
}
});

assertThat(artifactContent, is("artifact1"));
// Test download from upload-artifact@v4 infrastructure
artifactContent = artifacts.get(1).download((is) -> {
try (ZipInputStream zis = new ZipInputStream(is)) {
StringBuilder sb = new StringBuilder();

ZipEntry ze = zis.getNextEntry();
assertThat(ze.getName(), is("artifact2.txt"));

// the scanner has to be kept open to avoid closing zis
Scanner scanner = new Scanner(zis);
while (scanner.hasNextLine()) {
sb.append(scanner.nextLine());
}

return sb.toString();
}
});

assertThat(artifactContent, is("artifact2"));

// Test GHRepository#getArtifact(long) as we are sure we have artifacts around
GHArtifact artifactById = repo.getArtifact(artifacts.get(0).getId());
Expand Down
Loading
Loading