Commit eb626793 authored by Grisha Kruglov's avatar Grisha Kruglov
Browse files

Closes #6233: Git-based autopublish workflow

Adds a python script which is responsible for checking if there are any local changes,
and publishing to mavenLocal if there are.

Before, consuming projects were responsible for this logic; now, they can simply call this command.

This also behaves differently from what fenix did.
Before, to determine if there are changes we'd run the build, and see if any tasks were
actually performed (meaning, there were code changes that triggered a rebuild).
This way had its pros - it wouldn't consider changes to .gitignore, for example, as something that would
affect the build.
However, for the most common case, when there are no changes, this approach would still run through the build.
Doing so comes with a significant overhead of running through all of gradle's build phases, even if there isn't
actually anything to re-compile.

New approach optimizes for the common case. When there are no changes, we can now determine that almost instantly
by looking at an aggregate of git hash values for our project. And, by allowing consuming projects to call the python
script directly, we're skipping the gradle overhead as well.

The end result is a zero-cost auto-publication workflow.
parent 99bbd227
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -20,6 +20,7 @@ build/

# Local configuration file (sdk path, etc)
local.properties
.lastAutoPublishContentsHash

# Proguard folder generated by Eclipse
proguard/
+108 −0
Original line number Diff line number Diff line
#!/usr/bin/env python3

# Purpose: Publish android packages to local maven repo, but only if changed since last publish.
# Dependencies: None
# Usage: ./automation/publish_to_maven_local_if_modified.py

from pathlib import Path
import os
import time
import hashlib
import argparse
import re
import subprocess

def fatal_err(msg):
    print(f"\033[31mError: {msg}\033[0m")
    exit(1)

def run_cmd_checked(*args, **kwargs):
    """Run a command, throwing an exception if it exits with non-zero status."""
    kwargs["check"] = True
    return subprocess.run(*args, **kwargs)

def find_project_root():
    """Find the absolute path of the project repository root."""
    cur_dir = Path(__file__).parent
    while not Path(cur_dir, "LICENSE").exists():
        cur_dir = cur_dir.parent
    return cur_dir.absolute()

LAST_CONTENTS_HASH_FILE = ".lastAutoPublishContentsHash"

GITIGNORED_FILES_THAT_AFFECT_THE_BUILD = ["local.properties"]

parser = argparse.ArgumentParser(description="Publish android packages to local maven repo, but only if changed since last publish")
parser.parse_args()

root_dir = find_project_root()
if str(root_dir) != os.path.abspath(os.curdir):
    fatal_err(f"This only works if run from the repo root ({root_dir!r} != {os.path.abspath(os.curdir)!r})")

# Calculate a hash reflecting the current state of the repo.

contents_hash = hashlib.sha256()

contents_hash.update(
    run_cmd_checked(["git", "rev-parse", "HEAD"], capture_output=True).stdout
)
contents_hash.update(b"\x00")

# Git can efficiently tell us about changes to tracked files, including
# the diff of their contents, if you give it enough "-v"s.

changes = run_cmd_checked(["git", "status", "-v", "-v"], capture_output=True).stdout
contents_hash.update(changes)
contents_hash.update(b"\x00")

# But unfortunately it can only tell us the names of untracked
# files, and it won't tell us anything about files that are in
# .gitignore but can still affect the build.

untracked_files = []

changes_lines = iter(ln.strip() for ln in changes.split(b"\n"))
try:
    ln = next(changes_lines)
    # Skip the tracked files.
    while not ln.startswith(b"Untracked files:"):
        ln = next(changes_lines)
    # Skip instructional line about using `git add`.
    ln = next(changes_lines)
    # Now we're at the list of untracked files.
    ln = next(changes_lines)
    while ln:
        untracked_files.append(ln)
        ln = next(changes_lines)
except StopIteration:
    pass

untracked_files.extend(GITIGNORED_FILES_THAT_AFFECT_THE_BUILD)

# So, we'll need to slurp the contents of such files for ourselves.

for nm in untracked_files:
    with open(nm, "rb") as f:
        contents_hash.update(f.read())
    contents_hash.update(b"\x00")
contents_hash.update(b"\x00")

contents_hash = contents_hash.hexdigest()

# If the contents hash has changed since last publish, re-publish.

last_contents_hash = ""
try:
    with open(LAST_CONTENTS_HASH_FILE) as f:
        last_contents_hash = f.read().strip()
except FileNotFoundError:
    pass

if contents_hash == last_contents_hash:
    print("Contents have not changed, no need to publish")
else:
    print("Contents have changed, publishing")
    run_cmd_checked(["./gradlew", "publishToMavenLocal", f"-Plocal={time.time_ns()}"])
    with open(LAST_CONTENTS_HASH_FILE, "w") as f:
        f.write(contents_hash)
        f.write("\n")
 No newline at end of file
+3 −0
Original line number Diff line number Diff line
@@ -41,6 +41,9 @@ permalink: /changelog/
* **browser-tabstray**
  * The iconView is no longer required in the template.

* **Developer ergonomics**
  * Improved autoPublication workflow. See https://mozac.org/contributing/testing-components-inside-app for updated documentation.

# 38.0.0

* [Commits](https://github.com/mozilla-mobile/android-components/compare/v37.0.0...v38.0.0)
+75 −2
Original line number Diff line number Diff line
@@ -16,9 +16,82 @@ Before trying to integrate a modified component into an *external* app try to re

* Add a **user interface test** for a sample app: The UI test will prevent regressions in your sample app scenario and allows other developers to *replay* your scenario when changing component code.

## Using a local Maven repository to test local component code
## Testing local components code

Even if you are able to reproduce the scenario *inside* the repository you may still want to test your code with an external app consuming the component.  You can do that by *publishing* the component to your *local* Maven repository and configuring the app to pull the dependency from there.
Even if you are able to reproduce the scenario *inside* the repository you may still want to test your code with an external app consuming the component.  You can do that by *publishing* the component to your *local* Maven repository and configuring the app to pull the dependency from there. This can be achieved manually, or via an automated flow described below.

### Automated flow: using auto-publication to test local component code

*android-component* repository contains scripts necessary to automatically determine if there are any local changes, publish them to a local maven repository, and to configure consuming application to use the latest published local version.

Add the following to your project's `settings.gradle`. This code will execute during the project's Gradle initialization phase, and will trigger android-component's auto-publication scripts when `local.properties` is configured.

```
def runCmd(cmd, workingDir, successMessage, captureStdout=true) {
    def proc = cmd.execute(null, new File(workingDir))
    def standardOutput = captureStdout ? new ByteArrayOutputStream() : System.out
    proc.consumeProcessOutput(standardOutput, System.err)
    proc.waitFor()

    if (proc.exitValue() != 0) {
        throw new GradleException("Process '${cmd}' finished with non-zero exit value ${proc.exitValue()}");
    } else {
        log(successMessage)
    }
    return captureStdout ? standardOutput : null
}

Properties localProperties = null
String settingAndroidComponentsPath = "autoPublish.android-components.dir"

if (file('local.properties').canRead()) {
    localProperties = new Properties()
    localProperties.load(file('local.properties').newDataInputStream())
    log('Loaded local.properties')
}

if (localProperties != null) {
    localProperties.each { prop ->
        gradle.ext.set("localProperties.${prop.key}", prop.value)
    }

    String androidComponentsLocalPath = localProperties.getProperty(settingAndroidComponentsPath)

    if (androidComponentsLocalPath != null) {
        log("Enabling automatic publication of android-components from: $androidComponentsLocalPath")
        def publishAcCmd = ["./automation/publish_to_maven_local_if_modified.py"]
        runCmd(publishAcCmd, androidComponentsLocalPath, "Published android-components for local development.", false)
    } else {
        log("Disabled auto-publication of android-components. Enable it by settings '$settingAndroidComponentsPath' in local.properties")
    }
}
```

Also, add the following to application's `build.gradle`. This will configure your project to use the latest locally published version of `android-components`.
```
if (gradle.hasProperty('localProperties.autoPublish.android-components.dir')) {
    ext.acSrcDir = gradle."localProperties.autoPublish.android-components.dir"
    apply from: "../${acSrcDir}/substitute-local-ac.gradle"
}
```

Finally, to enable this workflow, in your project's `local.properties` file, add the following:
```
autoPublish.android-components.dir=../android-components
```

With all of the above done, your project will now be built against your local checkout of `android-components`. This automation is practically zero-cost - if there are no local changes in `android-components`, no additional work will be performed.

To disable this flow and test against a released version, comment out the `autoPublish` line in `local.properties`.

#### Hints for working with an auto-publication workflow
- it may be worth it to clean up your local .m2 directory now-and-then, as old builds start to accumulate
- after making changes to `android-components`, press `sync with gradle` in your project's Android Studio to see those changes reflected in your project
- simply pressing `play` in your project's Android Studio should always produce a build with latest `android-components`, even if you didn't `sync` beforehand.

### Manual flow: using a local Maven repository to test local component code

This is the fully manual version of the above flow. Generally not recommended for day-to-day use since it's error-prone and more cumbersome.

#### Setup version number

+1 −1
Original line number Diff line number Diff line
@@ -88,7 +88,7 @@ if (localProperties != null) {

    if (appServicesLocalPath != null) {
        logger.lifecycle("Enabling automatic publication of application-services from: $appServicesLocalPath")
        def publishAppServicesCmd = ["./gradlew", "autoPublishForLocalDevelopment"]
        def publishAppServicesCmd = ["./automation/publish_to_maven_local_if_modified.py"]
        runCmd(publishAppServicesCmd, appServicesLocalPath, "Published application-services for local development.")
    } else {
        logger.lifecycle("Disabled auto-publication of application-services. Enable it by settings '$settingAppServicesPath' in local.properties")