Replacing Fastlane with Shell scripts

Oct 3, 2024 · Follow on Twitter and Mastodon

After many, many years of great service, I’m looking to replace Fastlane with plain Shell script files, which I hope will result in faster builds and…less Ruby.

TLDR;

This article goes in-depth on how to create Shell scripts to manage many parts of a Swift Package’s lifecycle. If you’re just after the scripts and basic info, have a look at the SwiftPackageScripts project.

Background

I use Fastlane to build, test and generate new versions of my various open-source projects. While the setup has been quite complex earlier, the current one is pretty basic:

fastlane_version "2.129.0"

default_platform :ios

platform :ios do

  name = "EmojiKit"
  main_branch = "main"


  # Build ==================

  lane :build do |options|
    build_platform(platform: "iOS")
    build_platform(platform: "OS X")
    build_platform(platform: "tvOS")
    build_platform(platform: "watchOS")
    build_platform(platform: "xrOS")
  end

  lane :build_platform do |options|
    platform = options[:platform]
    sh("cd .. && xcodebuild -scheme " + name + " -derivedDataPath .build -destination 'generic/platform=" + platform + "';")
  end


  # Test ==================

  lane :test do
    test_platform(platform: "platform=iOS Simulator,name=iPhone 16")
  end

  lane :test_platform do |options|
    platform = options[:platform]
    sh("cd .. && xcodebuild test -scheme " + name + " -derivedDataPath .build -destination '" + platform + "' -enableCodeCoverage YES;")
  end


  # Version ================

  desc "Create a new version"
  lane :version do |options|
    version_validate
    version_build

    type = options[:type]
    version = version_bump_podspec(path: 'Version', bump_type: type)
    git_commit(path: "*", message: "Bump to #{version}")
    add_git_tag(tag: version)
    push_git_tags()
    push_to_git_remote()
  end
  
  desc "Validate that the SDK is ready for release"
  lane :version_validate do
    ensure_git_status_clean
    ensure_git_branch(branch: main_branch)
    swiftlint(strict: true)
  end
  
  desc "Validate that the repo is valid for release"
  lane :version_build do
    build
    test
  end

end

I used to have DocC (Apple’s documentation tool for Swift software) scripts in here, but have moved that part to a GitHub Actions, as described in this post.

We will now convert these lanes to plain Shell scripts, step by step, to eventually be able to remove Fastlane and all things that come with it.

Step 1 - Replacing the build lanes

To avoid having to use Fastlane to build the SDK, let’s convert these Fastfile lanes to script files:

lane :build do |options|
  build_platform(platform: "iOS")
  build_platform(platform: "OS X")
  build_platform(platform: "tvOS")
  build_platform(platform: "watchOS")
  build_platform(platform: "xrOS")
end

lane :build_platform do |options|
  platform = options[:platform]
  sh("cd .. && xcodebuild -scheme " + name + " -derivedDataPath .build -destination 'generic/platform=" + platform + "';")
end

Let’s first create a scripts/build_framework.sh script to replace the build_platform lane. It just takes the code from inside sh(...), removes the cd .. and adds some argument validation:

#!/bin/bash

# Verify that all required arguments are provided
if [ $# -ne 2 ]; then
    echo "Error: This script requires exactly two arguments"
    echo "Usage: $0 <TARGET> <PLATFORM>"
    exit 1
fi

TARGET=$1
PLATFORM=$2

xcodebuild -scheme $TARGET -derivedDataPath .build -destination generic/platform=$PLATFORM

To build a project called MyLib for iOS, we’d just have to write bash scripts/build_platform.sh MyLib iOS. The rest of the post assumes that you understand, and will not repeat how each script is called.

We can now create a build script that replaces the build lane, that builds a target for all platforms.

This will be a bit more complicated, since the scripts/build.sh script will locate the build_framework script in the same folder, make it executable, then call it for all platforms:

#!/bin/bash

# Exit immediately if a command exits with a non-zero status
set -e

# Verify that all required arguments are provided
if [ $# -eq 0 ]; then
    echo "Error: This script requires exactly one argument"
    echo "Usage: $0 <TARGET>"
    exit 1
fi

# Create local argument variables.
TARGET=$1

# Use the script folder to refer to other scripts.
FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
SCRIPT="$FOLDER/build_platform.sh"

# Make the script executable
chmod +x $SCRIPT

# A function that builds a specific platform
build_platform() {
    local platform=$1
    echo "Building for $platform..."
    if ! bash $SCRIPT $TARGET $platform; then
        echo "Failed to build $platform"
        return 1
    fi
    echo "Successfully built $platform"
}

# Array of platforms to build
platforms=("iOS" "macOS" "tvOS" "watchOS" "xrOS")

# Loop through platforms and build
for platform in "${platforms[@]}"; do
    if ! build_platform "$platform"; then
        exit 1
    fi
done

echo "All platforms built successfully!"

The reason for having two files, while we could just use the one-line from build_platform within the loop, is that it’s nice to be able to build a single platform with a single script.

With this in place, we can remove the Fastfile lanes and call the build script from version_build:

fastlane_version "2.129.0"

default_platform :ios

platform :ios do

  name = "EmojiKit"
  main_branch = "main"


  # Test ==================

  lane :test do
    test_platform(platform: "platform=iOS Simulator,name=iPhone 16")
  end

  lane :test_platform do |options|
    platform = options[:platform]
    sh("cd .. && xcodebuild test -scheme " + name + " -derivedDataPath .build -destination '" + platform + "' -enableCodeCoverage YES;")
  end


  # Version ================

  desc "Create a new version"
  lane :version do |options|
    version_validate
    version_build

    type = options[:type]
    version = version_bump_podspec(path: 'Version', bump_type: type)
    git_commit(path: "*", message: "Bump to #{version}")
    add_git_tag(tag: version)
    push_git_tags()
    push_to_git_remote()
  end

  desc "Validate that the SDK is ready for release"
  lane :version_validate do
    ensure_git_status_clean
    ensure_git_branch(branch: main_branch)
    swiftlint(strict: true)
  end

  desc "Validate that the repo is valid for release"
  lane :version_build do
    sh("cd .. && bash scripts/build.sh " + name)
    test
  end

end

Step 2 - Replacing the test lanes

To avoid having to use Fastlane to test the SDK, we can repeat the above steps for these lanes:

lane :test do
  test_platform(platform: "platform=iOS Simulator,name=iPhone 16")
end

lane :test_platform do |options|
  platform = options[:platform]
  sh("cd .. && xcodebuild test -scheme " + name + " -derivedDataPath .build -destination '" + platform + "' -enableCodeCoverage YES;")
end

Let’s first create a scripts/test_framework.sh script to replace the test_platform lane. Just like with the build script, we can rewrite the code in sh(...):

#!/bin/bash

# Verify that all required arguments are provided
if [ $# -ne 2 ]; then
    echo "Error: This script requires exactly two arguments"
    echo "Usage: $0 <TARGET> <PLATFORM>"
    exit 1
fi

TARGET=$1
PLATFORM="${2//_/ }"

xcodebuild test -scheme $TARGET -derivedDataPath .build -destination "$PLATFORM" -enableCodeCoverage YES;

This file uses a workaround to map _ to spaces in the platform name, since calling it with spaces will cause the platform to be interpreted as multiple arguments.

We can now create a scripts/test.sh script that replaces the test lane. It will be as complicated as the build script, since it needs to work in the same way:

#!/bin/bash

# Exit immediately if a command exits with a non-zero status
set -e

# Verify that all required arguments are provided
if [ $# -eq 0 ]; then
    echo "Error: This script requires exactly one argument"
    echo "Usage: $0 <TARGET>"
    exit 1
fi

# Create local argument variables.
TARGET=$1

# Use the script folder to refer to other scripts.
FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
SCRIPT="$FOLDER/test_platform.sh"

# Make the script executable
chmod +x $SCRIPT

# A function that tests a specific platform
test_platform() {
    local platform=$1
    echo "Testing for $platform..."
    if ! bash $SCRIPT $TARGET $platform; then
        echo "Failed to test $platform"
        return 1
    fi
    echo "Successfully tested $platform"
}

# Array of platforms to test
platforms=("platform=iOS_Simulator,name=iPhone_16")

# Loop through platforms and build
for platform in "${platforms[@]}"; do
    if ! test_platform "$platform"; then
        exit 1
    fi
done

echo "All platforms tested successfully!"

With this in place, we can remove the Fastfile lanes and call the test script from version_build:

fastlane_version "2.129.0"

default_platform :ios

platform :ios do

  name = "EmojiKit"
  main_branch = "main"

  desc "Create a new version"
  lane :version do |options|
    version_validate
    version_build

    type = options[:type]
    version = version_bump_podspec(path: 'Version', bump_type: type)
    git_commit(path: "*", message: "Bump to #{version}")
    add_git_tag(tag: version)
    push_git_tags()
    push_to_git_remote()
  end

  desc "Validate that the SDK is ready for release"
  lane :version_validate do
    ensure_git_status_clean
    ensure_git_branch(branch: main_branch)
    swiftlint(strict: true)
  end

  desc "Validate that the repo is valid for release"
  lane :version_build do
    sh("cd .. && bash scripts/build.sh " + name)
    sh("cd .. && bash scripts/test.sh " + name)
  end

end

Step 3 - Replacing the version validation lanes

To move even more things out of Fastfile, we must create a Shell script variant for the convenient ensure_git_status_clean and ensure_git_branch(branch: main_branch) Fastlane scripts.

Let’s replace these two git validation scripts with a single scripts/version_validate_git.sh script:

#!/bin/bash

# Exit immediately if a command exits with a non-zero status
set -e

# Verify that all required arguments are provided
if [ $# -eq 0 ]; then
    echo "Error: This script requires exactly one argument"
    echo "Usage: $0 <BRANCH>"
    exit 1
fi

# Create local argument variables.
BRANCH=$1

# Check if the current directory is a Git repository
if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then
    echo "Error: Not a Git repository"
    exit 1
fi

# Check for uncommitted changes
if ! git diff-index --quiet HEAD --; then
    echo "Error: Git repository is dirty. There are uncommitted changes."
    exit 1
fi

# Verify that we're on the correct branch
current_branch=$(git rev-parse --abbrev-ref HEAD)
if [ "$current_branch" != "$BRANCH" ]; then
    echo "Error: Not on the specified branch. Current branch is $current_branch, expected $1."
    exit 1
fi

# The Git repository validation succeeded.
echo "Git repository successfully validated for branch ($1)."
exit 0

The script uses git rev-parse to check that we’re in a git repo, then git diff-index to check if we’re on HEAD, then finally uses git rev-parse again to check if we’re on the correct branch.

We can then create a version_validate_project.sh script that performs other quality validations like linting, and running the build and test scripts:

#!/bin/bash

# Exit immediately if a command exits with a non-zero status
set -e

# Verify that all required arguments are provided
if [ $# -eq 0 ]; then
    echo "Error: This script requires exactly one argument"
    echo "Usage: $0 <TARGET>"
    exit 1
fi

# Create local argument variables.
TARGET=$1

# Use the script folder to refer to other scripts.
FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
BUILD="$FOLDER/build.sh"
TEST="$FOLDER/test.sh"

# A function that run a certain script and checks for errors
run_script() {
    local script="$1"
    shift  # Remove the first argument (script path) from the argument list

    if [ ! -f "$script" ]; then
        echo "Error: Script not found: $script"
        exit 1
    fi

    chmod +x "$script"
    if ! "$script" "$@"; then
        echo "Error: Script $script failed"
        exit 1
    fi
}

echo "Running SwiftLint"
if ! swiftlint; then
    echo "Error: SwiftLint failed."
    exit 1
fi

echo "Building..."
run_script "$BUILD" "$TARGET"

echo "Testing..."
run_script "$TEST" "$TARGET"

echo ""
echo "Project successfully validated!"
echo ""

We can now replace the Fastlane validations with version_validate_git & version_validate_project. We can also remove the version_build lane and move the build and test steps into version:

fastlane_version "2.129.0"

default_platform :ios

platform :ios do

  name = "EmojiKit"
  main_branch = "main"

  desc "Create a new version"
  lane :version do |options|
    sh("cd .. && bash scripts/version_validate_git.sh " + main_branch)
    sh("cd .. && bash scripts/version_validate_project.sh" + name)

    type = options[:type]
    version = version_bump_podspec(path: 'Version', bump_type: type)
    git_commit(path: "*", message: "Bump to #{version}")
    add_git_tag(tag: version)
    push_git_tags()
    push_to_git_remote()
  end

end

If version_validate_git and version_validate_project successfully validate the git repo and project, the script bumps the version number, creates a new tag and then pushes all changes.

Let’s now proceed with replacing these last version bump lane steps with a couple of Shell scripts.

Step 4 - Replacing the version bump lanes

The next step is a little trickier, since we have to create a Shell script variant of the pretty awesome version_bump_podspec script, which bumps the current version and returns the new version number.

The script accepts a “bump type” which can be major, minor, patch, etc. This lets us bump a version in different ways, depending on the kind of version we want to create.

Although the script has the word podspec in its name, you use it with any Ruby file. For instance, my projects just have a Version file that defines the version number like this:

Version::Number.new do |v|
  v.version = '1.0.0'
end

Still…why do we need a separate version file to track the version number when we already have git? We can remove this dependency to make things more flexible.

Instead of keeping the Version and version_bump_podspec, I will use git to get the latest version, then replace the bump script with a manual version number step.

Let’s first add this scripts/version_number.sh script that returns the last semver conforming version:

#!/bin/bash

# Exit immediately if a command exits with a non-zero status
set -e

# Check if the current directory is a Git repository
if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then
    echo "Error: Not a Git repository"
    exit 1
fi

# Fetch all tags
git fetch --tags > /dev/null 2>&1

# Get the latest semver tag
latest_version=$(git tag -l --sort=-v:refname | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)

# Check if we found a version tag
if [ -z "$latest_version" ]; then
    echo "Error: No semver tags found in this repository" >&2
    exit 1
fi

# Print the latest version
echo "$latest_version"

This script fetches all tags, then uses a regex to pick the latest semantic version, for instance 1.2.3.

We can now create a scripts/version_number_bump.sh script that calls the version_number script, then displays the version number and waits for the user to enter which version number to bump to:

#!/bin/bash

# Exit immediately if a command exits with a non-zero status
set -e

# Use the script folder to refer to other scripts.
FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
SCRIPT="$FOLDER/version_number.sh"

# Get the latest version
VERSION=$($SCRIPT)

if [ $? -ne 0 ]; then
    echo "Failed to get the latest version"
    exit 1
fi

# Print the current version
echo "The current version is: $VERSION"

# Function to validate semver format, including optional -rc.<INT> suffix
validate_semver() {
    if [[ $1 =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then
        return 0
    else
        return 1
    fi
}

# Prompt user for new version
while true; do
    read -p "Enter the new version number: " NEW_VERSION

    if validate_semver "$NEW_VERSION"; then
        break
    else
        echo "Invalid version format. Please use semver format (e.g., 1.2.3, v1.2.3, 1.2.3-rc.1, etc.)."
        exit 1
    fi
done

The semver validation function above accepts a -rc.x suffix, to let us create release candidates.

Before we proceed, lets pause and to take a step back to look at the current Fastfile setup:

fastlane_version "2.129.0"

default_platform :ios

platform :ios do

  name = "EmojiKit"
  main_branch = "main"

  desc "Create a new version"
  lane :version do |options|
    sh("cd .. && bash scripts/validate_git.sh " + main_branch)
    sh("cd .. && bash scripts/validate_project.sh")
    sh("cd .. && bash scripts/build.sh " + name)
    sh("cd .. && bash scripts/test.sh " + name)

    type = options[:type]
    version = version_bump_podspec(path: 'Version', bump_type: type)
    git_commit(path: "*", message: "Bump to #{version}")
    add_git_tag(tag: version)
    push_git_tags()
    push_to_git_remote()
  end

end

We could replace the type and version lines with the script above, but we’re actually ready to leave Fastlane behind altogether, so let’s instead create Shell script versions for the last four lines:

git commit -am "Bump to #$NEW_VERSION"
git tag $NEW_VERSION
git push --tags
git push -u origin HEAD

However, since we no longer update the version in the Version file, we can remove the git commit and just have this:

git push -u origin HEAD
git tag $NEW_VERSION
git push --tags

Add these three lines to the version_bump.sh and it now looks like this:

#!/bin/bash

# Exit immediately if a command exits with a non-zero status
set -e

# Use the script folder to refer to other scripts.
FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
SCRIPT="$FOLDER/version_number.sh"

# Get the latest version
VERSION=$($SCRIPT)

if [ $? -ne 0 ]; then
    echo "Failed to get the latest version"
    exit 1
fi

# Print the current version
echo "The current version is: $VERSION"

# Function to validate semver format, including optional -rc.<INT> suffix
validate_semver() {
    if [[ $1 =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then
        return 0
    else
        return 1
    fi
}

# Prompt user for new version
while true; do
    read -p "Enter the new version number: " NEW_VERSION

    if validate_semver "$NEW_VERSION"; then
        break
    else
        echo "Invalid version format. Please use semver format (e.g., 1.2.3, v1.2.3, 1.2.3-rc.1, etc.)."
        exit 1
    fi
done

git push -u origin HEAD
git tag $NEW_VERSION
git push --tags

That’s all we need to be able to replace the version_bump_podspec script with a flexible alternative.

As a bonus, we can also remove the Version file and Fastlane folder, and remove Fastlane from .gitignore, since we can now create a Shell script that replaces the entire version lane.

Step 4 - Replacing the versioning lane

The next step is to replace the version lane with this scripts/version_create.sh script, which takes a target and a branch, then performs all required validations before calling version_number_bump:

#!/bin/bash

# Exit immediately if a command exits with a non-zero status
set -e

# Verify that all required arguments are provided
if [ $# -ne 2 ]; then
    echo "Error: This script requires exactly two arguments"
    echo "Usage: $0 <BUILD_TARGET> <GIT_BRANCH>"
    exit 1
fi

# Create local argument variables.
BUILD_TARGET="$1"
GIT_BRANCH="$2"

# Use the script folder to refer to the platform script.
FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
VALIDATE_GIT="$FOLDER/version_validate_git.sh"
VALIDATE_PROJECT="$FOLDER/version_validate_project.sh"
VERSION_BUMP="$FOLDER/version_number_bump.sh"

# A function that run a certain script and checks for errors
run_script() {
    local script="$1"
    shift # Remove the first argument (the script path)

    if [ ! -f "$script" ]; then
        echo "Error: Script not found: $script"
        exit 1
    fi

    chmod +x "$script"
    if ! "$script" "$@"; then
        echo "Error: Script $script failed"
        exit 1
    fi
}


# Execute the pipeline steps
echo "Starting pipeline for BUILD_TARGET: $BUILD_TARGET, GIT_BRANCH: $GIT_BRANCH"

echo "Validating Git..."
run_script "$VALIDATE_GIT" "$GIT_BRANCH"

echo "Validating Project..."
run_script "$VALIDATE_PROJECT" "$BUILD_TARGET"

echo "Bumping version..."
run_script "$VERSION_BUMP"

echo ""
echo "Version created successfully!"
echo ""

The script will call multiple scripts in sequence, to perform all the steps that we have created earlier. We are more explicit in our argument naming here, since this script is more general.

…and with that, we’re done! We have now replaced Fastlane and the Version file with a couple of basic Shell scripts, which work both separatele or in combination with each other.

Not take a look at how to use these scripts and add them into the package continuous integration.

Step 5 - Creating new package versions

We can now create new versions of our package with bash scripts/version_create.sh [PackageName] [MainBranch]. The script will validate, build and test, then create and bump a new version number.

To avoid having to type the project name and branch every time we, we can create a project-specific script file in the project root, that does this for us. Let’s name it version_create.sh as well:

SCRIPT="scripts/version_create.sh"
chmod +x $SCRIPT
chmod +x version_create.sh
bash $SCRIPT PackageName MainBranch

With this file in place, we can create new versions by just typing bash version_create.sh. And since the file makes itself executable, you can just type ./version_create.sh for all subsequent versions.

Step 6 - Integrate with GitHub workflows

With these scripts in place, creating GitHub Action build runners is a breeze. For instance, this is a .github/workflows/build.yml file that is run on every push to the main branch:

name: Build Runner

on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]

jobs:
  build:
    runs-on: macos-15
    steps:
      - uses: actions/checkout@v3
      - uses: maxim-lobanov/setup-xcode@v1
        with:
          xcode-version: '16.0'
      - name: Build all platforms
        run: bash scripts/build.sh ${{ github.event.repository.name }}
      - name: Test iOS
        run: bash scripts/test.sh ${{ github.event.repository.name }}

This file will run the build script to build the package for all platforms, then the test script to run unit tests on our selected platforms.

Note how we use {{ github.event.repository.name }} to pass in the repository name to the scripts. This means that the script file can be project agnostic, and easily copied between projects.

We can also create a DocC runner, to automatically publish new documentation every time we push to main. But first, let’s create a scripts/docc.sh script file that performs the operation:

#!/bin/bash

# Verify that all required arguments are provided
if [ $# -eq 0 ]; then
    echo "Error: This script requires exactly one argument"
    echo "Usage: $0 <TARGET>"
    exit 1
fi

TARGET=$1
TARGET_LOWERCASED=$(echo "$1" | tr '[:upper:]' '[:lower:]')

swift package resolve;

xcodebuild docbuild -scheme $1 -derivedDataPath /tmp/docbuild -destination 'generic/platform=iOS';

$(xcrun --find docc) process-archive \
  transform-for-static-hosting /tmp/docbuild/Build/Products/Debug-iphoneos/$1.doccarchive \
  --output-path .build/docs \
  --hosting-base-path "$TARGET";

echo "<script>window.location.href += \"/documentation/$TARGET_LOWERCASED\"</script>" > .build/docs/index.html;

This script builds DocC documentation, then transforms it for static hosting in the .build/docs build folder. The last line injects a redirect from the (empty) root page to the actual documentation root.

We can now create a .github/workflows/docc.yml that makes GitHub Actions build and publish DocC documentation on every push to the main branch:

name: DocC Runner

on:
  push:
    branches: ["main"]

# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
  contents: read
  pages: write
  id-token: write

# Allow one concurrent deployment
concurrency:
  group: "pages"
  cancel-in-progress: true
  
jobs:
  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: macos-15
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - id: pages
        name: Setup Pages
        uses: actions/configure-pages@v4
      - name: Select Xcode version
        uses: maxim-lobanov/setup-xcode@v1
        with:
          xcode-version: '16.0'
      - name: Build DocC
        run: bash scripts/docc.sh ${{ github.event.repository.name }}
      - name: Upload artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: '.build/docs'
      - id: deployment
        name: Deploy to GitHub Pages
        uses: actions/deploy-pages@v4

This runner triggers the scripts/docc.sh script, then grabs the generated, transformed web content and uploads it as an artifact, after which it becomes published on GitHub Pages.

Conclusion

This became a bit longer than intended, but I’m happy that I managed to replace the entire Fastlane setup with Shell scripts. This now run faster, with more flexibility. And less Ruby.

If you want to try this out, I have published it as a this GitHub repo. You can just add the scripts and GitHub workflows to any Swift package to easily build, test, and create new versions of it.

I’d love to hear what you think of this approach, and if you think any of the scripts can be improved. Reach out in the comment section below, or comment on social media, using the links below.

Thank you for reading!

Discussions & More

If you found this interesting and would like to share your thoughts, please comment in the Disqus section below or reply to this tweet or this toot.

Follow on Twitter and Mastodon to be notified when new content & articles are published.