Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .github/workflows/api-tracker-java25.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,68 @@ jobs:
run: mvn clean install

- name: Run API Tracker
id: tracker
run: |
mvn exec:java \
-pl json-java21-api-tracker \
-Dexec.mainClass="io.github.simbo1905.tracker.ApiTrackerRunner" \
-Dexec.args="INFO" \
-Djava.util.logging.ConsoleHandler.level=INFO

# Read outputs into environment
echo "fingerprint=$(cat target/api-tracker/fingerprint.txt)" >> $GITHUB_OUTPUT
echo "has_differences=$(cat target/api-tracker/has-differences.txt)" >> $GITHUB_OUTPUT
Comment on lines +43 to +45

Choose a reason for hiding this comment

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

P1 Badge Read tracker outputs from module target dir

In this workflow step the outputs are read from target/api-tracker/..., but ApiTrackerRunner.writeOutputFiles writes to target/api-tracker relative to the JVM working directory, which under mvn exec:java -pl json-java21-api-tracker is the module base. That means the files land in json-java21-api-tracker/target/api-tracker, so cat target/api-tracker/*.txt (and the artifact upload path) will be missing in CI and the step will fail before issue creation. Consider using json-java21-api-tracker/target/api-tracker or setting a working-directory so the paths align.

Useful? React with 👍 / 👎.

Copy link
Owner Author

Choose a reason for hiding this comment

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

This was tested and works correctly. Maven exec:java runs from the project root, not the module directory. The logs from CI show: 'Output files written to: /home/runner/work/java.util.json.Java21/java.util.json.Java21/target/api-tracker' which is the project root's target/api-tracker. Issue #114 was created successfully using these paths.


- name: Upload API report artifact
uses: actions/upload-artifact@v4
with:
name: api-tracker-report
path: target/api-tracker/
retention-days: 90

- name: Check for existing issue
if: steps.tracker.outputs.has_differences == 'true'
id: check_issue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
FINGERPRINT="${{ steps.tracker.outputs.fingerprint }}"
echo "Looking for existing issue with hash:${FINGERPRINT}"

# Search for open issues with this fingerprint
EXISTING=$(gh issue list --state open --search "hash:${FINGERPRINT} in:title" --json number --jq '.[0].number // empty')

if [ -n "$EXISTING" ]; then
echo "Found existing issue #${EXISTING}"
echo "issue_exists=true" >> $GITHUB_OUTPUT
echo "existing_issue=${EXISTING}" >> $GITHUB_OUTPUT
else
echo "No existing issue found"
echo "issue_exists=false" >> $GITHUB_OUTPUT
fi

- name: Create issue for API differences
if: steps.tracker.outputs.has_differences == 'true' && steps.check_issue.outputs.issue_exists == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
FINGERPRINT="${{ steps.tracker.outputs.fingerprint }}"
SUMMARY=$(cat target/api-tracker/summary.md)

# Create issue body
cat > /tmp/issue_body.md << EOF
${SUMMARY}

## Details

- **Fingerprint**: \`hash:${FINGERPRINT}\`
- **Workflow Run**: [${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
- **Artifact**: Download the full JSON report from the workflow artifacts

This issue was auto-generated by the API Tracker workflow.
EOF

gh issue create \
--title "API drift detected [hash:${FINGERPRINT}]" \
--body-file /tmp/issue_body.md \
--label "api-tracking,upstream-sync"
81 changes: 60 additions & 21 deletions .github/workflows/daily-api-tracker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up JDK 24
- name: Set up JDK 25
uses: actions/setup-java@v4
with:
java-version: '24'
java-version: '25'
distribution: 'temurin'

- name: Cache Maven dependencies
Expand All @@ -35,29 +35,68 @@ jobs:
run: mvn clean install

- name: Run API Tracker
id: tracker
run: |
mvn exec:java \
-pl json-java21-api-tracker \
-Dexec.mainClass="io.github.simbo1905.tracker.ApiTrackerRunner" \
-Dexec.args="INFO" \
-Djava.util.logging.ConsoleHandler.level=INFO

- name: Create issue if differences found
if: failure()
uses: actions/github-script@v7

# Read outputs into environment
echo "fingerprint=$(cat target/api-tracker/fingerprint.txt)" >> $GITHUB_OUTPUT
echo "has_differences=$(cat target/api-tracker/has-differences.txt)" >> $GITHUB_OUTPUT

- name: Upload API report artifact
uses: actions/upload-artifact@v4
with:
script: |
const title = 'API differences detected between local and upstream';
const body = `The daily API tracker found differences between our local implementation and upstream.

Check the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.

Date: ${new Date().toISOString().split('T')[0]}`;

github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: title,
body: body,
labels: ['api-tracking', 'upstream-sync']
});
name: api-tracker-report
path: target/api-tracker/
retention-days: 90

- name: Check for existing issue
if: steps.tracker.outputs.has_differences == 'true'
id: check_issue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
FINGERPRINT="${{ steps.tracker.outputs.fingerprint }}"
echo "Looking for existing issue with hash:${FINGERPRINT}"

# Search for open issues with this fingerprint
EXISTING=$(gh issue list --state open --search "hash:${FINGERPRINT} in:title" --json number --jq '.[0].number // empty')

if [ -n "$EXISTING" ]; then
echo "Found existing issue #${EXISTING}"
echo "issue_exists=true" >> $GITHUB_OUTPUT
echo "existing_issue=${EXISTING}" >> $GITHUB_OUTPUT
else
echo "No existing issue found"
echo "issue_exists=false" >> $GITHUB_OUTPUT
fi

- name: Create issue for API differences
if: steps.tracker.outputs.has_differences == 'true' && steps.check_issue.outputs.issue_exists == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
FINGERPRINT="${{ steps.tracker.outputs.fingerprint }}"
SUMMARY=$(cat target/api-tracker/summary.md)

# Create issue body
cat > /tmp/issue_body.md << EOF
${SUMMARY}

## Details

- **Fingerprint**: \`hash:${FINGERPRINT}\`
- **Workflow Run**: [${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
- **Artifact**: Download the full JSON report from the workflow artifacts

This issue was auto-generated by the Daily API Tracker workflow.
EOF

gh issue create \
--title "API drift detected [hash:${FINGERPRINT}]" \
--body-file /tmp/issue_body.md \
--label "api-tracking,upstream-sync"
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
Expand Down Expand Up @@ -947,4 +949,145 @@ static String fetchUpstreamSource(String className) {
FETCH_CACHE.put(className, source);
return source;
}

/// Generates a SHA256 fingerprint of the differences (first 7 chars)
/// Uses only essential, stable information: class names and change types (sorted)
/// Used for deduplicating GitHub issues
/// @param report the full comparison report
/// @return 7-character fingerprint or "0000000" if no differences
static String generateFingerprint(JsonObject report) {
if (getDifferentApiCount(report) == 0) {
return "0000000";
}

// Build a stable, sorted representation of just the essential diff info
final var differences = (JsonArray) report.members().get("differences");
final var stableLines = new ArrayList<String>();

for (final var diff : differences.values()) {
final var diffObj = (JsonObject) diff;
final var status = ((JsonString) diffObj.members().get("status")).value();

if (!"DIFFERENT".equals(status)) continue;

final var className = ((JsonString) diffObj.members().get("className")).value();
final var classDiffs = (JsonArray) diffObj.members().get("differences");

if (classDiffs != null) {
for (final var change : classDiffs.values()) {
final var changeObj = (JsonObject) change;
final var type = ((JsonString) changeObj.members().get("type")).value();
final var methodValue = changeObj.members().get("method");
final var method = methodValue instanceof JsonString js ? js.value() : "";
// Create stable line: "ClassName:changeType:methodName"
stableLines.add(className + ":" + type + ":" + method);
Comment on lines +978 to +983

Choose a reason for hiding this comment

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

P2 Badge Include non-method diff details in fingerprint

The fingerprint line only uses className:type:method, but for diff types like fieldsChanged, constructorsChanged, inheritanceChanged, attributeChanged, and modifiersChanged there is no method and the specific field/attribute lists or counts are ignored. That means a class can change again within the same diff type (e.g., different field set or different attribute mismatch) and still hash to the same fingerprint, so the workflow will treat a new drift as a duplicate and skip opening a new issue. Including the relevant detail (attribute name, field list, counts, etc.) in the fingerprint avoids missing updated drift.

Useful? React with 👍 / 👎.

Copy link
Owner Author

Choose a reason for hiding this comment

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

Acknowledged - for now the fingerprint covers className:changeType:methodName which handles the common case of method changes. For non-method changes (fieldsChanged, inheritanceChanged, etc.) the type is still included which will differentiate them. Adding full detail extraction would add complexity - deferring to a follow-up if needed.

}
}
}

// Sort for deterministic ordering
Collections.sort(stableLines);
final var stableString = String.join("\n", stableLines);

try {
final var digest = MessageDigest.getInstance("SHA-256");
final var hash = digest.digest(stableString.getBytes(StandardCharsets.UTF_8));
final var hexString = new StringBuilder();
for (final var b : hash) {
hexString.append(String.format("%02x", b));
}
return hexString.substring(0, 7);
} catch (NoSuchAlgorithmException e) {
LOGGER.warning("SHA-256 not available, using fallback fingerprint");
return String.format("%07x", stableString.hashCode() & 0xFFFFFFF);
}
}

/// Extracts the differentApi count from a report summary
/// @param report the comparison report
/// @return the count of classes with different APIs
private static long getDifferentApiCount(JsonObject report) {
final var summary = (JsonObject) report.members().get("summary");
if (summary == null) {
return 0;
}
final var differentApiValue = summary.members().get("differentApi");
if (differentApiValue instanceof JsonNumber num) {
return num.toNumber().longValue();
}
return 0;
}

/// Generates a terse human-readable summary of the API differences
/// Suitable for GitHub issue body
/// @param report the full comparison report
/// @return markdown-formatted summary
static String generateSummary(JsonObject report) {
final var sb = new StringBuilder();
final var summary = (JsonObject) report.members().get("summary");
final var differences = (JsonArray) report.members().get("differences");

final var totalClasses = ((JsonNumber) summary.members().get("totalClasses")).toNumber().longValue();
final var matchingClasses = ((JsonNumber) summary.members().get("matchingClasses")).toNumber().longValue();
final var differentApi = getDifferentApiCount(report);
final var missingUpstream = ((JsonNumber) summary.members().get("missingUpstream")).toNumber().longValue();

sb.append("## API Comparison Summary\n\n");
sb.append("| Metric | Count |\n");
sb.append("|--------|-------|\n");
sb.append("| Total Classes | ").append(totalClasses).append(" |\n");
sb.append("| Matching | ").append(matchingClasses).append(" |\n");
sb.append("| Different | ").append(differentApi).append(" |\n");
sb.append("| Missing Upstream | ").append(missingUpstream).append(" |\n\n");

if (differentApi > 0) {
sb.append("## Changes Detected\n\n");

for (final var diff : differences.values()) {
final var diffObj = (JsonObject) diff;
final var status = ((JsonString) diffObj.members().get("status")).value();

if (!"DIFFERENT".equals(status)) continue;

final var className = ((JsonString) diffObj.members().get("className")).value();
sb.append("### ").append(className).append("\n\n");

final var classDiffs = (JsonArray) diffObj.members().get("differences");
if (classDiffs != null) {
for (final var change : classDiffs.values()) {
final var changeObj = (JsonObject) change;
final var type = ((JsonString) changeObj.members().get("type")).value();
final var methodValue = changeObj.members().get("method");
final var method = methodValue instanceof JsonString js ? js.value() : "unknown";

final var emoji = switch (type) {
case "methodRemoved" -> "➖";
case "methodAdded" -> "➕";
case "methodChanged" -> "🔄";
case "inheritanceChanged" -> "🔗";
case "fieldsChanged" -> "📦";
case "constructorsChanged" -> "🏗️";
default -> "❓";
};

sb.append("- ").append(emoji).append(" **").append(type).append("**: `").append(method).append("`\n");
}
}
sb.append("\n");
}
}

sb.append("---\n");
final var timestamp = ((JsonString) report.members().get("timestamp")).value();
sb.append("*Generated by API Tracker on ").append(timestamp.split("T")[0]).append("*\n");

return sb.toString();
}

/// Checks if there are any API differences in the report
/// @param report the comparison report
/// @return true if differentApi > 0
static boolean hasDifferences(JsonObject report) {
return getDifferentApiCount(report) > 0;
}
Comment on lines 1090 to 1092

Choose a reason for hiding this comment

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

medium

The logic to extract the differentApi count from the report summary is duplicated across generateFingerprint, generateSummary, and this method (hasDifferences). This duplication can lead to maintenance issues. Consider refactoring this logic into a private static helper method to improve code reuse and robustness.

For example:

private static long getDifferentApiCount(JsonObject report) {
    final var summary = (JsonObject) report.members().get("summary");
    if (summary == null) {
        return 0;
    }
    final var differentApiValue = summary.members().get("differentApi");
    if (differentApiValue instanceof JsonNumber num) {
        return num.toNumber().longValue();
    }
    return 0;
}

This method could then be simplified to return getDifferentApiCount(report) > 0;, and the other methods could use getDifferentApiCount as well.

Copy link
Owner Author

Choose a reason for hiding this comment

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

Fixed - extracted getDifferentApiCount() helper method to reduce duplication

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import jdk.sandbox.java.util.json.Json;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.logging.ConsoleHandler;
import java.util.logging.Level;
import java.util.logging.Logger;
Expand Down Expand Up @@ -43,7 +46,26 @@ public static void main(String[] args) {

// Pretty print the report
System.out.println("=== Comparison Report ===");
System.out.println(Json.toDisplayString(report, 2));
final var jsonOutput = Json.toDisplayString(report, 2);
System.out.println(jsonOutput);

// Generate fingerprint and summary
final var fingerprint = ApiTracker.generateFingerprint(report);
final var summary = ApiTracker.generateSummary(report);
final var hasDiffs = ApiTracker.hasDifferences(report);

System.out.println();
System.out.println("=== Fingerprint ===");
System.out.println("hash:" + fingerprint);

if (hasDiffs) {
System.out.println();
System.out.println("=== Summary ===");
System.out.println(summary);
}

// Write outputs to files for workflow artifact upload
writeOutputFiles(jsonOutput, fingerprint, summary, hasDiffs);

} catch (Exception e) {
System.err.println("Error during comparison: " + e.getMessage());
Expand All @@ -53,6 +75,34 @@ public static void main(String[] args) {
}
}

private static void writeOutputFiles(String jsonOutput, String fingerprint, String summary, boolean hasDiffs) {
try {
// Create output directory
final var outputDir = Path.of("target", "api-tracker");
Files.createDirectories(outputDir);

// Write full JSON report
Files.writeString(outputDir.resolve("report.json"), jsonOutput);

// Write fingerprint
Files.writeString(outputDir.resolve("fingerprint.txt"), fingerprint);

// Write summary markdown
Files.writeString(outputDir.resolve("summary.md"), summary);

// Write has-differences flag for workflow
Files.writeString(outputDir.resolve("has-differences.txt"), String.valueOf(hasDiffs));

System.out.println();
System.out.println("Output files written to: " + outputDir.toAbsolutePath());
} catch (IOException e) {
System.err.println("Error: Could not write output files: " + e.getMessage());
//noinspection CallToPrintStackTrace
e.printStackTrace();
System.exit(1);
}
Comment on lines 98 to 103

Choose a reason for hiding this comment

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

high

Catching IOException and only printing a warning to stderr can mask a critical failure. If the workflow relies on these output files, the build should fail explicitly. It's better to treat this as a fatal error, print an error message, and exit with a non-zero status code, similar to how other exceptions are handled in the main method.

Suggested change
} catch (IOException e) {
System.err.println("Warning: Could not write output files: " + e.getMessage());
}
} catch (IOException e) {
System.err.println("Error: Could not write output files: " + e.getMessage());
//noinspection CallToPrintStackTrace
e.printStackTrace();
System.exit(1);
}

Copy link
Owner Author

Choose a reason for hiding this comment

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

Fixed - now exits with non-zero status code on IOException.

}

private static void configureLogging(Level level) {
// Get root logger
final var rootLogger = Logger.getLogger("");
Expand Down