Friday, November 21, 2025

AutoPkg GitHub Actions and Issues

AutoPkg GitHub Actions and Issues

Automating AutoPkg

For many years we have been running AutoPkg recipes daily using tools like Jenkins, gitlabs CI, and GitHub Actions. These recipes feed our endpoint management systems and fill a niche to quickly package and cache software for deployment. While we were able to receive status emails for the run of recipes, one are of improvement was to automatically create any issues for recipes that failed. If that sounds like an interesting topic, keep reading!

Auto-Running Recipes

Setting up GitHub actions to run AutoPkg recipes is covered in many places, so I won't dive too deep into that area. I hope it suffices to say that we are using GitHub Actions with a on-premise Mac mini that was set up to run the AutoPkg recipes locally. The GitHub Action we use triggers the run of these recipes using a text file that lists each one. This is the command we use to trigger and write the logs to a file named "autopkg-log.txt":

sudo -H -u macadmin bash -c '/usr/local/bin/autopkg run --recipe-list ~/Library/AutoPkg/RecipeRepos/com.github.company.autopkg-recipes/_common.txt) | tee ${{ github.workspace }}/autopkg-log.txt'

Our GitHub repo of recipes "com.github.company.autopkg-recipes' contains the "_common.txt" file that lists each of the recipes names to run; E.G. "GoogleChrome.jamf". Lines that are commented out with "#" are skipped. The results of the AutoPkg run are sent to both the STDOUT for showing results in the GitHub Actions and a new text file for our processing. The results are also saved in a file named "autopkg-log.txt", which stays in  a local copy of the repo on the Mac mini. We do not save the file and it is replaced each run. Thanks to AutoPkg displaying error results in a nice block format, we can scrape for this within the file and do something with it!

Getting JSON-y

Processing text is the next step in our desire for determination of detrimental recipes. Another GitHub Action takes over to parse the results of the text file into a JSON format that is easier to iterate and automate with. The following is an example of failed recipes output that we are looking to create issues for:

The following recipes failed:
    Anaconda3-PSU.jamf
        Error in local.jamf.Anaconda3-PSU: Processor: URLTextSearcher: Error: No match found on URL: https://www.anaconda.com/download/success
    Anki-PSU.jamf
        Error in local.jamf.Anki-PSU: Processor: CodeSignatureVerifier: Error: Code signature verification failed. Note that all verifications can be disabled by setting the variable DISABLE_CODE_SIGNATURE_VERIFICATION to a non-empty value.

Getting some assistance from the Internet lead me to find a solution for creating a JSON array for the issues. This uses a simple Shell script to parse each failure and create a new "title" and "body" json entry in a list. This list is then placed into the "issues" json key and saved in a file locally:

 - name: Create Json Errors

              id: extract

              run: |

                  FILE="${{ github.workspace }}/autopkg-log.txt"   # adjust to your file path


                  # Grab the section starting at "The following recipes failed:"

                  SECTION=$(awk '/The following recipes failed:/,/^[[:space:]]*$/' "$FILE")


                  # Parse into JSON

                  JSON=$(echo "$SECTION" | awk '

                      BEGIN { RS="\n"; FS=":"; recipe=""; error=""; first=1 }

                      /^    / {

                      if ($0 ~ /^[[:space:]]+[A-Za-z0-9.-]+$/) {

                          recipe=$1; gsub(/^[[:space:]]+/, "", recipe);

                      } else {

                          error=$0; gsub(/^[[:space:]]+/, "", error);

                          if (!first) { printf(","); } else { first=0; }

                          printf("{\"title\":\"%s\",\"body\":\"%s\"}", recipe, error);

                      }

                      }

                  ')

                  echo "{\"issues\":[${JSON}]}" > ${{ github.workspace }}/autopkg-log.json

We're using the "github.workspace" variable to help us ensure the file is written and available locally. After we run this action, the "autopkg-log.json" file is available for our next step.

Making Issuses

The internet was a lovely help again with this task, which we want to process the json results and create a new issue, if one does not already exists, for the recipe. As we are able to iterate through the "issues" json and retrieve the "title" and "body", it's a simple github-script away from wrapping it all with a nice bow:

- name: Parse JSON and create issues
              uses: actions/github-script@v7
              with:
                  script: |
                      const fs = require('fs');
                      const data = fs.readFileSync('${{ github.workspace }}/autopkg-log.json', 'utf8');
                      const issues = JSON.parse(data).issues;

                      for (const issue of issues) {
                        // Search for existing issues with the same title
                        const existing = await github.rest.issues.listForRepo({
                            owner: context.repo.owner,
                            repo: context.repo.repo,
                            state: 'open',
                            per_page: 100
                        });

                        const found = existing.data.find(i => i.title === issue.title);

                        if (found) {
                            console.log(`Issue "${issue.title}" already exists (#${found.number}), skipping.`);
                        } else {
                            await github.rest.issues.create({
                            owner: context.repo.owner,
                            repo: context.repo.repo,
                            title: issue.title,
                            body: issue.body,
                            labels: ["bug"]
                            });
                            console.log(`Created new issue: "${issue.title}"`);
                        }
                        }

This code finds the local file in the location we saved it, which requires running the action in the same set of steps in GitHub Actions or on the same runner. Once the file is located, we process each issue using the json "title" and "body". Existing issues are checked for matching titles and new issues are only created when they do not already exist.

Final Thoughts

It is possible to pass the json between steps, and jobs, using GitHub variables, which may be better way for those who are running AutoPkg completely in the cloud. Creating and relying on a file isn't necessarily the best method, however it does allow for a backup of the issues locally on a runner and for other processes to access the same data.

As with any automation, testing is a big part of ensuring this will work in the way you expect. For example, when a processor we use was updated ALL of the recipes need updated. Does it make sense to create an issue for each one when there is a common error? This example does not take those considerations into account and should be used as a starting point for your own solutions.

-rusty

Updates:

Updated "autopkg run" to use "--recipe-list": https://github.com/autopkg/autopkg/wiki/Running-Multiple-Recipes#recipe-lists Thanks elios!

Tuesday, January 24, 2023

Good bye loginhooks, Hello launchdaemons...

Recently our team began testing macOS Ventura (13.x) in our production testing environment. One of the first issues we noticed was that macOS no longer was going to run our loginhooks, either log in or log out...we knew it was coming.

Shell scripts have the ability to trap the exit of a script and perform additional things. We can exploit that to make a Login and Logout script with the same file. 

https://community.jamf.com/t5/jamf-pro/how-can-i-use-launchd-to-run-logout-script/td-p/42209

So, let's launch the scripts using /Library/LaunchAgents/org.example.loginhook.plist pointing to the scripts? Sure, that'll work...but no root access for anything we wanted to do "special" for the user. Examples of such might be mounting a shadow disk image to /Applications/, setting display or system sleep times, rebooting on specific conditions, etc...

Ok, how about we add a /Library/LaunchDaemon/org.example.login.plist that launches a signed app, which launches a script to run scripts with admin rights. (Am I the baddie?) We also add a PathState to watch for a /tmp/org.example.login file. When that's created, it runs the script. In this case, we wrap the script in an app.

Platypus.app provides a quick method to wrap a script and codesign it for deployment:

sudo codesign -f -s "Developer ID" -v orgLoginHook.app

Once the app is created and signed, it's placed in /Library/Example/Hools/orgLoginHook.app. This works, but the user can't then stop the script at logout...it just goes on and on. I can trigger the LaunchDaemon by writing a file in /tmp/ (PathState), but it won't stop if I delete the files. In fact, the PathState just gets triggered again when it's deleted. Luckily, it's still running...

How does a standard user stop a LaunchDaemon? 

sudo launchctl stop org.example.login

It's possible to allow the standard user to use sudo in the context. (This can't be the best way?) Add the following line to a new file named /etc/sudoers.d/org:

ALL ALL=NOPASSWD: /bin/launchctl stop org.example.login

Users can now stop the script running and trigger the logout events. Logout Hooks are restored.

Comment and let me know what I'm doing wrong or how you also like to abuse your operating system. 

Thanks, rusty.


::Files::

/Library/LaunchDaemons/org.example.login.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>org.example.login</string>
    <key>PathState</key>
    <dict>
<key>/tmp/org.example.login</key>
<true/>
    </dict>
    <key>RunAtLoad</key>
    <false/>
    <key>ProgramArguments</key>
    <array>
 <string>/Library/Example/Hooks/orgLoginHook.app/Contents/MacOS/orgLoginHook</string>
    </array>
</dict>
</plist>

/Library/LaunchAgents/org.example.loginhook.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>org.example.loginhook</string>
<key>ProgramArguments</key>
<array>
<string>/Library/Example/Hooks/orgLogin.sh</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>

orgLoginHook
#! /bin/zsh
#3.0
# runs everything in /Library/Example/Hooks beginning with LI (for LogIn), LO (for Logout)
#--------------------------------------------------------------------------------------------------
#-- Log - Echo messages with date and timestamp
#--------------------------------------------------------------------------------------------------
Log () {
logText=$1
# indent lines except for program entry and exit
if [[ "${logText}" == "-->"* ]];then
echo 
logText="${logText}`basename $0`: launched..."
else
if [[ "${logText}" == "<--"* ]];then
logText="${logText}`basename $0`: ...terminated" 
else
logText="   ${logText}"
fi
fi
date=$(/bin/date)
echo "${date/E[DS]T /} ${logText}"
}
loginmain () {
Log "-->"
if [[ -d ${HOOKSDIR} ]]; then
for hook in ${HOOKSDIR}/LI*; do
if [[ -s ${hook} && -x ${hook} ]]; then
Log "Executing ${hook} for $loggedInUserID..."
# run the item
${hook} "$loggedInUser"
if [[ $? -ne 0 ]]; then
Log "$0 ${hook} failed!"
fi
fi
done
fi
Log "<--" >> /Library/Example/org/login.log
}

logoutmain () {
Log "-->"
if [[ -d ${HOOKSDIR} ]]; then
for hook in ${HOOKSDIR}/LO*; do
if [[ -s ${hook} && -x ${hook} ]]; then
Log "$0 Executing ${hook} for $loggedInUser... "
# run the item
${hook} "$loggedInUser"
if [[ $? -ne 0 ]]; then
Log "$0 ${hook} failed!"
fi
fi
done
fi
    rm /tmp/org.example.login
Log "<--"
}

export PATH=/bin:/usr/bin:/usr/local/bin:/sbin:/usr/sbin:/usr/local/sbin

HOOKSDIR="/Library/Example/Hooks"
loggedInUser=$( scutil <<< "show State:/Users/ConsoleUser" | awk '/Name :/ && ! /loginwindow/ { print $3 }' )
onLogout() {
    echo 'Logging out' >> /Library/Example/org/login.log
    logoutmain $loggedInUser 2>&1 >> /Library/Example/org/login.log
    exit
}
loginmain $loggedInUser 2>&1 >> /Library/Example/org/login.log

trap 'onLogout' SIGINT SIGHUP SIGTERM
while true; do
    sleep 21600 &
    wait $!
done


orgLogin.sh
#! /bin/zsh
#3.0

#--------------------------------------------------------------------------------------------------
#-- Log - Echo messages with date and timestamp
#--------------------------------------------------------------------------------------------------
Log ()
{
logText=$1
# indent lines except for program entry and exit
if [[ "${logText}" == "-->"* ]];then
logText="${logText}`basename $0`: launched..."
else
if [[ "${logText}" == "<--"* ]];then
logText="${logText}`basename $0`: ...terminated" 
else
logText="   ${logText}"
fi
fi
date=$(/bin/date)
echo "${date/E[DS]T /} ${logText}"
}

loginmain () {
Log "-->"
    touch /tmp/org.example.login
Log "<--"
}

logoutmain () {
Log "-->"
    rm /tmp/org.example.login
    sudo launchctl stop org.example.login
Log "<--"
}

onLogout() {
    echo 'Logging out' >> /Library/Example/org/login.log
    logoutmain $loggedInUser 2>&1 >> /Library/Example/org/login.log
    exit
}

export PATH=/bin:/usr/bin:/usr/local/bin:/sbin:/usr/sbin:/usr/local/sbin
loggedInUser=$( scutil <<< "show State:/Users/ConsoleUser" | awk '/Name :/ && ! /loginwindow/ { print $3 }' )

loginmain $loggedInUser 2>&1 >> /Library/Example/org/login.log

trap 'onLogout' SIGINT SIGHUP SIGTERM
while true; do
    sleep 21600 &
    wait $!
done

Tuesday, March 22, 2022

SPSS 27 Silent Install for macOS!

 

SPSS 27 comes in a convenient installer pkg, which seems to be easy to deploy and effective without a user logged in! Yay!

The application and license file paths have changed...however it's even easier to license for an Auth Code

"/Applications/IBM SPSS Statistics 27/Resources/Activation/licenseactivator" "${AUTHCODE}"


Finally, I have nothing to complain about this year...

Tuesday, June 16, 2020

AutoDesk 2021 Silent Install


Installing AutoDesk Suite

Despite COVID, Fall classes are quickly approaching and that means it's time to deploy new software! Up next, AutoDesk AutoDesk 2021, Maya 2020, and Mudbox 2020. 

AutoCAD 2021

After attempting to install using their --silent flag and it failing due to no user being logged in, I decided to rip apart the DMG and see what it does...
"Silent" Install flag:
https://knowledge.autodesk.com/support/autocad/troubleshooting/caas/CloudHelp/cloudhelp/2021/ENU/Installation-AutoCAD/files/Install-ACDMAC/Installation-AutoCAD-Install-ACDMAC-acdmac-install-product-silently-html-html.html
"Silent" install for the whole suite: 
http://help.autodesk.com/view/INSTALL_LICENSE/ENU/?guid=GUID-4D762D36-E521-4D8D-8A48-B41FE2DDF381 

After I was able to get the install done, I then had to figure out how to register it so it doesn't prompt the user. In the past, network Registration was done with a license file, but I'm not sure this is needed any longer: 
https://knowledge.autodesk.com/search-result/caas/sfdcarticles/sfdcarticles/Using-the-licpath-lic-file-for-Autodesk-reg-network-license-enabled-products.html

The license file didn't really work as I expected, it always prompted the user for the LM server. Eventually I found the following document on pointing to a license manager:

https://knowledge.autodesk.com/support/3ds-max/troubleshooting/caas/sfdcarticles/sfdcarticles/Use-Installer-Helper.html

This tool helps us point our software to the license manager and prevent the software from prompting the user. We use this for all of the AutoDesk software.



"AUTOCAD21_LICENSE_SERVER"="LIC_SERVER"
"AUTOCAD21_pk"="777M1"
"AUTOCAD21_pv"="2021.0.0.F"
#Set Locale
wait defaults write .GlobalPreferences AppleLocale -string "en-US"
# Clean Out OLD/Failed Installs
wait rm -R "/Applications/Autodesk/AutoCAD*"
/usr/bin/hdiutil attach -quiet -nobrowse -mountpoint "/tmp/AutoCAD" "PATH_TO/Autodesk_AutoCAD.dmg"
installer -pkg "/tmp/AutoCAD/Install Autodesk AutoCAD 2021 for Mac.app/Contents/Helper/ObjToInstall/autocad2021.pkg" -tgt /
installer -pkg "/tmp/AutoCAD/Install Autodesk AutoCAD 2021 for Mac.app/Contents/Helper/Packages/Licensing/AdskLicensing-10.1.0.3194-mac-installer.pkg" -tgt /
installer -pkg "/tmp/AutoCAD/Install Autodesk AutoCAD 2021 for Mac.app/Contents/Helper/Packages/AdSSO/AdSSO-v2.pkg" -tgt /
installer -pkg "/tmp/AutoCAD/Install Autodesk AutoCAD 2021 for Mac.app/Contents/Helper/ObjToInstall/lib.pkg" -tgt /
#Create a License Server File with the contents:
#
#SERVER {parameter "LICENSE_SERVER21"} 000000000000
#USE_SERVER
#
#Place the file in "/Library/Application Support/Autodesk/AdskLicensingService/777M1_2021.0.0.F/licpath.lic"
# Reload the Licensing Service
launchctl unload "/Library/LaunchDaemons/com.autodesk.AdskLicensingService.plist"
launchctl load "/Library/LaunchDaemons/com.autodesk.AdskLicensingService.plist"
installer -pkg "/tmp/AutoCAD/Install Autodesk AutoCAD 2021 for Mac.app/Contents/Helper/ObjToInstall/licreg.pkg" -tgt /

/usr/bin/hdiutil detach -force "/tmp/AutoCAD"
"/Library/Application Support/Autodesk/AdskLicensing/Current/helper/AdskLicensingInstHelper" change -pk $AUTOCAD21_pk -pv $AUTOCAD21_pv -ls "@${AUTOCAD21_LICENSE_SERVER}" -lm NETWORK

Maya 2020

Following AutoCad, I ripped apart Maya and found the following packages that needed installed:

"MAYA20_LICENSE_SERVER"="LIC_SERVER"
"MAYA20_pk"="657L1"
"MAYA20_pv"="2020.0.0.F"
/usr/bin/hdiutil attach -quiet -nobrowse -mountpoint "/tmp/Autodesk_Maya" "PATH_TO/Autodesk_Maya.dmg"
installer -pkg "/tmp/Autodesk_Maya/Install Maya 2020.app/Contents/Helper/Packages/Maya/Maya_core2020.pkg" -tgt / installer -pkg "/tmp/Autodesk_Maya/Install Maya 2020.app/Contents/Helper/Packages/Licensing/AdskLicensing-9.2.1.2399-mac-installer.pkg" -tgt / installer -pkg "/tmp/Autodesk_Maya/Install Maya 2020.app/Contents/Helper/Packages/Licensing/adlmframework18.pkg" -tgt / installer -pkg "/tmp/Autodesk_Maya/Install Maya 2020.app/Contents/Helper/Packages/Licensing/adlmapps18.pkg" -tgt / installer -pkg "/tmp/Autodesk_Maya/Install Maya 2020.app/Contents/Helper/Packages/AdSSO/AdSSO-v2.pkg" -tgt / installer -pkg "/tmp/Autodesk_Maya/Install Maya 2020.app/Contents/Helper/Packages/Licensing/adlmflexnetclient.pkg" -tgt / installer -pkg "/tmp/Autodesk_Maya/Install Maya 2020.app/Contents/Helper/Packages/Maya/Maya_AdLMconf2020.pkg" -tgt / installer -pkg "/tmp/Autodesk_Maya/Install Maya 2020.app/Contents/Helper/Packages/Maya/MtoA.pkg" -tgt / installer -pkg "/tmp/Autodesk_Maya/Install Maya 2020.app/Contents/Helper/Packages/Maya/bifrost.pkg" -tgt / installer -pkg "/tmp/Autodesk_Maya/Install Maya 2020.app/Contents/Helper/Packages/Maya/SubstanceInMaya-2.1.2-2020-Darwin.pkg" -tgt / installer -pkg "/tmp/Autodesk_Maya/Install Maya 2020.app/Contents/Helper/Packages/Maya/motion-library.maya-1.1.0.pkg" -tgt /
mkdir -p "/Library/Application Support/Autodesk/AdskLicensingService/657L1_2020.0.0.F"
#Create a License Server File with the contents: # #SERVER "${MAYA20_LICENSE_SERVER"} 000000000000 #USE_SERVER # #Place the file in "/Library/Application Support/Autodesk/AdskLicensingService/657L1_2020.0.0.F/licpath.lic"
/usr/bin/hdiutil detach -force "/tmp/Autodesk_Maya"
"/Library/Application Support/Autodesk/AdskLicensing/Current/helper/AdskLicensingInstHelper" change -pk "$MAYA20_pk" -pv "$MAYA20_pv" -lm NETWORK -ls "@${MAYA20_LICENSE_SERVER"}"


Mudbox 2020

With Mudbox, I reduced to just the packages and the AdskLicensingInstHelper, which seemed to do all the things needed to point to the server and retrieve a license.

"MUDBOX20_LICENSE_SERVER"="LIC_SERVER"
"MUDBOX20_pk"="498L1" 
"MUDBOX20_pv"="2020.0.0.F"
rm -Rf "/Applications/Autodesk/Mudbox*"
/usr/bin/hdiutil attach -quiet -nobrowse -mountpoint "/tmp/Autodesk_Mudbox" "PATH_TO/Autodesk_Mudbox.dmg"
installer -pkg "/tmp/Autodesk_Mudbox/Install Mudbox 2020.app/Contents/Packages/ADLM/AdskLicensing-9.2.1.2399-mac-installer.pkg" -tgt / installer -pkg "/tmp/Autodesk_Mudbox/Install Mudbox 2020.app/Contents/Packages/ADLM/AdSSO-v2.pkg" -tgt / installer -pkg "/tmp/Autodesk_Mudbox/Install Mudbox 2020.app/Contents/Packages/ADLM/adlmapps18.pkg" -tgt / installer -pkg "/tmp/Autodesk_Mudbox/Install Mudbox 2020.app/Contents/Packages/ADLM/adlmflexnetclient.pkg" -tgt / installer -pkg "/tmp/Autodesk_Mudbox/Install Mudbox 2020.app/Contents/Packages/ADLM/adlmframework18.pkg" -tgt / installer -pkg "/tmp/Autodesk_Mudbox/Install Mudbox 2020.app/Contents/Packages/Mudbox/Mudbox_AdLMconf2020.pkg" -tgt / wait installer -pkg "/tmp/Autodesk_Mudbox/Install Mudbox 2020.app/Contents/Packages/Mudbox/Mudbox_core2020.pkg" -tgt /
wait /usr/bin/hdiutil detach -force "/tmp/Autodesk_Mudbox"
"/Library/Application Support/Autodesk/AdskLicensing/Current/helper/AdskLicensingInstHelper" change -pk "$MUDBOX20_pk" -pv "$MUDBOX20_pv" -lm NETWORK -ls "@${MUDBOX20_LICENSE_SERVER}"

Anyway, that's been my last four weeks! I hope this helps someone else, possibly even future me?!