From 11e6d542ddf4d2d8e0ecb1a3b3ac1827c1c8fe46 Mon Sep 17 00:00:00 2001 From: Rob Lemley Date: Mon, 7 Oct 2024 10:16:25 -0400 Subject: [PATCH] Initial Build and Sign Automation (#8192) --- .github/workflows/shippable_builds.yml | 229 +++++++++++++++++++++++++ docs/CI/Release_Automation.md | 71 ++++++++ docs/CI/publish_hold.png | Bin 0 -> 10530 bytes 3 files changed, 300 insertions(+) create mode 100644 .github/workflows/shippable_builds.yml create mode 100644 docs/CI/Release_Automation.md create mode 100644 docs/CI/publish_hold.png diff --git a/.github/workflows/shippable_builds.yml b/.github/workflows/shippable_builds.yml new file mode 100644 index 00000000000..2f6001780dd --- /dev/null +++ b/.github/workflows/shippable_builds.yml @@ -0,0 +1,229 @@ +name: Shippable Build & Signing +on: + workflow_dispatch: + +jobs: + get_environment: + runs-on: ubuntu-latest + outputs: + releaseEnv: ${{ steps.getReleaseEnv.outputs.result }} + steps: + - uses: actions/github-script@v7 + id: getReleaseEnv + with: + result-encoding: string + script: | + const RELEASE_ENVS = { + "refs/heads/main": "thunderbird_daily", + "refs/heads/beta": "thunderbird_beta", + "refs/heads/release": "thunderbird_release", + }; + + if (context.ref in RELEASE_ENVS) { + return RELEASE_ENVS[context.ref]; + } else { + core.setFailed(`Unknown branch ${context.ref} for shippable builds!`) + return ""; + } + + dump_config: + runs-on: ubuntu-latest + needs: get_environment + environment: ${{ needs.get_environment.outputs.releaseEnv }} + outputs: + matrixInclude: ${{ vars.MATRIX_INCLUDE }} + appName: ${{ vars.APP_NAME }} + releaseType: ${{ vars.RELEASE_TYPE }} + tagPrefix: ${{ vars.TAG_PREFIX }} + steps: + - name: Dump Vars context + id: variables + env: + VARS_CONTEXT: ${{ toJSON(vars) }} + MATRIX_INCLUDE: ${{ vars.MATRIX_INCLUDE }} + run: | + echo "$VARS_CONTEXT" + echo "$MATRIX_INCLUDE" + + + build_unsigned: + runs-on: ubuntu-latest + timeout-minutes: 90 + needs: [dump_config, get_environment] + strategy: + matrix: + include: "${{ fromJSON(needs.dump_config.outputs.matrixInclude) }}" + environment: ${{ needs.get_environment.outputs.releaseEnv }} + steps: + - uses: actions/checkout@v4 + + - name: Copy CI gradle.properties + shell: bash + run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build It + shell: bash + env: + PACKAGE_FORMAT: ${{ matrix.packageFormat }} + PACKAGE_FLAVOR: ${{ matrix.packageFlavor }} + APP_NAME: ${{ vars.APP_NAME }} + RELEASE_TYPE: ${{ vars.RELEASE_TYPE }} + run: | + BUILD_CMD="${PACKAGE_FORMAT}" + if [[ "$PACKAGE_FORMAT" = "apk" ]]; then + BUILD_CMD="assemble" + fi + # ^ upper-case first character of bash string + BUILD_COMMAND="${BUILD_CMD}${PACKAGE_FLAVOR^}${RELEASE_TYPE^}" + echo "BUILDING: :${APP_NAME}:${BUILD_COMMAND}" + ./gradlew clean :${APP_NAME}:${BUILD_COMMAND} --no-build-cache --no-configuration-cache + echo "Status: $?" + + - name: Move apps to upload directory + shell: bash + env: + PACKAGE_FORMAT: ${{ matrix.packageFormat }} + PACKAGE_FLAVOR: ${{ matrix.packageFlavor }} + APP_NAME: ${{ vars.APP_NAME }} + RELEASE_TYPE: ${{ vars.RELEASE_TYPE }} + OUT_BASE: ${{ vars.APP_NAME }}/build/outputs/${{ matrix.packageFormat }} + UPLOAD_PATH: "uploads" + run: | + mkdir -p "${UPLOAD_PATH}" + if [[ "${PACKAGE_FORMAT}" = "apk" ]]; then + OUT_PATH="${OUT_BASE}/${PACKAGE_FLAVOR}/${RELEASE_TYPE}" + OUT_FILE="${APP_NAME}-${PACKAGE_FLAVOR}-${RELEASE_TYPE}-unsigned.apk" + RENAMED_FILE="${OUT_FILE/-unsigned/}" + elif [[ "${PACKAGE_FORMAT}" = "bundle" ]]; then + OUT_PATH="${OUT_BASE}/${PACKAGE_FLAVOR}${RELEASE_TYPE^}" + OUT_FILE="${APP_NAME}-${PACKAGE_FLAVOR}-${RELEASE_TYPE}.aab" + RENAMED_FILE="${OUT_FILE}" + else + echo "PACKAGE_FORMAT $PACKAGE_FORMAT is unknown. Exiting." + exit 23 + fi + if [[ -f "${OUT_PATH}/${OUT_FILE}" ]]; then + mv -f "${OUT_PATH}/${OUT_FILE}" "${UPLOAD_PATH}/${RENAMED_FILE}" + else + echo "Build file ${OUT_PATH}/${OUT_FILE} not found. Exiting." + ls -l ${OUT_PATH} + exit 24 + fi + echo "Upload contents:" + ls -l ${UPLOAD_PATH}/ + + - name: Upload unsigned + uses: actions/upload-artifact@v4 + env: + UPLOAD_PATH: "uploads" + with: + name: unsigned-${{ vars.APP_NAME}}-${{ matrix.packageFormat }}-${{ matrix.packageFlavor }} + path: ${{ env.UPLOAD_PATH }}/ + if-no-files-found: error + + sign_mobile: + runs-on: ubuntu-latest + strategy: + matrix: + include: "${{ fromJSON(needs.dump_config.outputs.matrixInclude) }}" + environment: ${{ needs.dump_config.outputs.appName }}_${{ needs.dump_config.outputs.releaseType }}_${{ matrix.packageFlavor }} + needs: [build_unsigned, dump_config] + env: + APP_NAME: ${{ needs.dump_config.outputs.appName }} + RELEASE_TYPE: ${{ needs.dump_config.outputs.releaseType }} + steps: + - uses: actions/download-artifact@v4 + with: + name: unsigned-${{ env.APP_NAME }}-${{ matrix.packageFormat }}-${{ matrix.packageFlavor }} + path: uploads/ + + - uses: noriban/sign-android-release@5f144321d3c7c2233266e78b42360345d8bbe403 # v5.1 + name: Sign package + with: + releaseDirectory: uploads/ + signingKeyBase64: ${{ secrets.SIGNING_KEY }} + alias: ${{ secrets.KEY_ALIAS }} + keyPassword: ${{ secrets.KEY_PASSWORD }} + keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} + + - name: Remove JKS file + shell: bash + run: | + rm -f uploads/*.jks + + - name: Upload signed + uses: actions/upload-artifact@v4 + with: + name: signed-${{ env.APP_NAME}}-${{ matrix.packageFormat }}-${{ matrix.packageFlavor }} + if-no-files-found: error + path: | + uploads/*-signed.apk + uploads/*.aab + + pre_publish: + # This is a holding job meant to require approval before proceeding with the publishing jobs below + # The environment has a deployment protection rule requiring approval from a set of named reviewers + # before proceeding. + environment: publish_hold + needs: [sign_mobile] + runs-on: ubuntu-latest + steps: + - name: Approval + shell: bash + run: | + true + + github_release: + runs-on: ubuntu-latest + needs: [ pre_publish, dump_config ] + environment: gh-releases + env: + APP_NAME: ${{ needs.dump_config.outputs.appName }} + RELEASE_TYPE: ${{ needs.dump_config.outputs.releaseType }} + TAG_PREFIX: ${{ needs.dump_config.outputs.tagPrefix }} + PACKAGE_FORMAT: "apk" + PACKAGE_FLAVOR: "foss" + UPLOADS: "uploads" + steps: + - uses: actions/download-artifact@v4 + with: + # We need to extract the version name from only one of the packages, use the foss apk + name: signed-${{ env.APP_NAME }}-${{ env.PACKAGE_FORMAT }}-${{ env.PACKAGE_FLAVOR }} + path: ${{ env.UPLOADS }}/ + + - name: Get Tag Name + shell: bash + run: | + APKANALYZER="${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/apkanalyzer" + APK_FILE="${APP_NAME}-${PACKAGE_FLAVOR}-${RELEASE_TYPE}-signed.apk" + _version=$(${APKANALYZER} manifest version-name "${UPLOADS}/${APK_FILE}") + _tag="${TAG_PREFIX}_${_version//./_}" + echo "Tag Name: ${_tag}" + echo "Apk File: ${APK_FILE}" + echo "TAG_NAME=${_tag}" >> $GITHUB_ENV + echo "APK_FILE=${APK_FILE}" >> $GITHUB_ENV + + - name: App Token Generate + uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ vars.RELEASER_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASER_APP_PRIVATE_KEY }} + + - name: Publish + uses: softprops/action-gh-release@v2 + with: + token: ${{ steps.app-token.outputs.token }} + target_commitish: ${{ github.sha }} + tag_name: ${{ env.TAG_NAME }} + fail_on_unmatched_files: true + files: | + ${{ env.UPLOADS }}/${{ env.APK_FILE }} diff --git a/docs/CI/Release_Automation.md b/docs/CI/Release_Automation.md new file mode 100644 index 00000000000..00dd40ed11d --- /dev/null +++ b/docs/CI/Release_Automation.md @@ -0,0 +1,71 @@ +# Release Automation Setup + +Release automation is triggered by the workflow_dispatch event on the "Shippable Build & Signing" +workflow. + +GitHub environments are used to set configuration variables for each application +and release type. The environment is selected when triggering the workflow. You must +also select the appropriate branch to run the workflow on. The environments are only +accessible by the branch they are associated with + +## Build Environments + +- thunderbird_beta +- thunderbird_daily +- thunderbird_release +- thunderbird_debug + +The variables set in these environments are non-sensitive and are used by the build job. + +- APP_NAME: app-thunderbird | app-k9 +- TAG_PREFIX: THUNDERBIRD | K9MAIL +- RELEASE_TYPE: debug | daily | beta | release +- MATRIX_INCLUDE: + - This is a JSON string used to create the jobs matrix. For example, for + Thunderbird beta, the (YAML) value would be: + ```yaml + - packageFormat: bundle + packageFlavor: full + - packageFormat: apk + packageFlavor: foss + ``` + That would build `bundleFullBeta` and `assembleFossBeta`. + +## Signing Environments + +There are also "secret" environments that are used by the signing job. + +An "upload" secret environment and a "signing" secret environment are needed. Currently the environment names are based +on the appName, releaseType, and packageFlavor. So `app-thunderbird_beta_full` which would have the upload +signing configuration for Thunderbird Beta set up. This could be improved. +The secrets themselves are from https://github.com/noriban/sign-android-release: + +```yaml +signingKey: ${{ secrets.SIGNING_KEY }} +alias: ${{ secrets.KEY_ALIAS }} +keyPassword: ${{ secrets.KEY_PASSWORD }} +keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} +``` + +## Publishing Hold Environment + +The "publish_hold" is shared by all application variants and is used by the "pre_publish" job. +It has no secrets or variables, but "Required Reviewers" is set to trusted team members who oversee releases. The +effect is that after package signing completes, the publishing jobs that depend on it will not run until released +manually. + +![publish hold](publish_hold.png) + +## Github Releases Environment + +"gh_releases" contains the Client Id and Private Key for a Github App that's used by the "actions/create-github-app-token' +to generate a token with the appropriate permissions to create and tag a Github release. + +| | Name | Description | +| -------- | ------------------------ | ------------------------------- | +| Variable | RELEASER_APP_CLIENT_ID | The Client ID of the github app | +| Secret | RELEASER_APP_PRIVATE_KEY | The private key of the app | + +### App Permissions + +**TODO** diff --git a/docs/CI/publish_hold.png b/docs/CI/publish_hold.png new file mode 100644 index 0000000000000000000000000000000000000000..343e489d88210bca70f9c63227cc432a76e79286 GIT binary patch literal 10530 zcmchdXH?V8x9=?=y$B*5j1&dwNRy^irB~@7y+{c{=mfz~l%~?9H|f2XfCK>{bb&}q z2uKYi^iH@ukN)pD>%6}A%!{m9Gn3!U+Ouc&%y&NTb+l9|NSR2lUAso{;<=LEwQD!g zS9vSqTUWo4pL)U9u033Tq4dna&vI)%GVsnMUVa-Vza5x`J1SMuDd2qiT;bjXWR!qF zA%R=-L)63P6!(=syuHVD@1Zhv!Mo=~C1H96Y6THUh10K*cRn1+;ZQ-t4&rTfZPOH- zfrLo^EQFV+qr>pvCR@Azh?Lh#2p)Cm9~$*1sUa^J$o}Sw?Svq-xBpH4#EO!Ca-Smq zpA|mO1)PUi?I^TJr~?XS9ubq_>7PLGWSPt~f4YJOanh=bC&%!an26K~tF|pZhIH<_ z@J#Fn)A4V#hkYBU%F_$i+vd4sDmA!w|KAjF@|-)_m593J=ulEms&W&xEVZ7?oA!v!yRq0O( zPuS#RoJlRq^kjh9Z-8uj{+8Qmi_{AnNq19g{VCWdXMnx`V|*D1AmdN&hvNUo!XKjT z&;=YW+O#Y7_Bbaw#g_)F(t1F(vo*@g48)ejU2U~@$ugV z?w*HHqJJDq_x)#U=y=(WYg!}B&UNEHlcU~5ZfDMaJ?yG>^|ifUmGA(p8J|8`|0&!|W- zwx={xU1K57|DPf1lB4(AZHXWJGxvF`w-hC1aSSlAp$$LlxigPf7xW%zppw6mKiOK7sQz{1iz6Y( z&pm7*1-e4fvX6a_x40T=V_@R#bo}H-Sti>ry4sZpr0uL?d+%G8R6A4onCT$) zn;<>y$HcVQwKCP3AUnm0>e9X@Q~el=KpLZjH`Z!#U1_XV`MyvDMco>UeHBI zEspE^Bz8N>xc+7cm;AHE{(j=Uv3NsLK(Nt*LN9G9mZf{6g;p56X{^CMc^70p|1c-0 zaLi$tD^*}fMH;?(`gwe2Zgfkyx%Du3LZmWKAZfz4?84W2fHBowKmnoF3lhv`+3E!C ziuw$$`d~8LXzA(822>)bnr5edG<)dEz&n&n9H*(&Cr^L>gHCpu$3d$P<^!CJ9Tw>HEMRi2soFCr zRFrJ+x;fSK9#QA}s8D2>m*LxA3@ZY2=MQcLRB1g8=w&0P5~}nXmME50AtzS` zLCbCE$mi2O?C)PNFmkTy-eoVJyx}u*TZ8yO#6!P9?5>qRtFo4wcX z2Y6#_nkqhdqVoKbgQNARu2${X&qs089YI?o$s#0Xx)OP}Ge5dCUF^jH+SgRHGkU4< ze5Qnf=<}l$z^V$aNk|@N0o{F_TzhRR9{U7bh2Dgb{*o!lun3Mj?rb7J)a_M!Bnk!v zGc83|tO6hG;liv1Oc}Bz-!NnCRu?st^bO!B!RiZhB?sBw1CVUS{?n9atKkx#l0hs9 zoZe@TyU*3*KkoL?JJ~RbTi&urnNv3HeHLU{cE^QcI<_E#pnT&XqL2+`%26$^NVcMG zTK3UkZ;3x@NULilG>J4~q?0@-Ob89+9=@yol&2&|1zS9}r)sV~KTPdzQ0~@lw|*To zxTfoaiJ6ros|g=Fx;U=`wt*_I{3dS0(+97f5>z$y&-O@X;TZ|6ao-a#WYdwJ&=dEb z@Dnqzh$y#e4q$#45&v@eW<=1}M%L45dT~$?libTBDmE!f*1(_~FuRmbt$lFe+nHkr zvPXbnAVnW{u-RG8JfHk3A*V6?=~&YRs6c&XfGn5;%DqAq6M#E;#_*oY{z4M0|Hw9z~I4l@%=SkQ}k=BBM@3Tk(;yBRwa($twmdv`t?PSn$ zO9lHv#dO0{!@ZQXNblj-%t4hIXtr>5astxFbkC0f~*oem#Ld zqbb{yL*mbM9@e^%wZ!Sc&t5-v6y9l?WzN_Dd5?AH2B8vE2 zj0EGaMM_-vreV|Kz^8;djeFh~zvLRpS6zy97m4X4XF5m=)hxmS#QBMKjs&ca@~%6r zJ2r$Yn=))~v7OWHUE|peI!6~WjDc7v3s|}#)a&mB-w8y!cioYqs3=#|Ud9@ye0+^J z--8F0iFL0nccOzd?rJ#mL_O)au8%DHB0tf2$EA^^?*po8(S1X)z}|jQYl5Vl-Gm*S zH1i5y=v}aU_uN*k?VRFC@ls32tdM@@^GSas0LHfa-Mnavo!Q`fI?&!`maI&6~Q$Wei%u%B=aJK^Uw0JG? zt{R!sDg=J%aMhrGf9KgT()|179DH0L6R{f3ZI$(w$3aYPdlJJNiD&-&5x;Q5K@2fl zLC>TuL;3R}Y0Y+8pi?G@$lVn6djQ^3nM*g=N8uE;&2@I0F|*`?NSd}ukQG)d%%3s$ zTf__31{zbDa1104tJM`v^29mx3+?d8Yv!s<}ud+ zB7+9_)Vb~*@7U4_L{hN2@|=x?;PVIMPOGa5*-lKLE?j#0`m9s7iS4H)mc#3jQdenKEpwe!KUm! zk;t1%l|F2+Ayo-MbLJVisl*l|*jf7aVIS}g3~^qsIk|K3;h?n15LO70BEi#qwm(}r zxo&u)girlimJq11kg|mlxIZD)hM}RMDa1c&!rDgMby#L9^tIZkp+tvgMwTN+EV7(Y z9Y-GnEG=(TB))(K$o5hTK(RDn4`j5F;sw2+RheA8Is%vH68oy&1!XvCExT!@041N5 z_gM)uO{Nx@oomgG;(COzooeri(j3-7#hN2}w*n_m)+8Qs>d+swa{A%Uwj{p}e~Gss zqAaWRk4`aboX!tM=!rXhX8?68cSPp|#7;J_T<+bMW0oM)cNo-12+m^q1V7M=L#S|i zD&gx@@*i`j3LcbdmmwzQP?S6jU$7rEiH+<%a&V@!I8Tx9dCzT4QA)zc!Uz*-eM~>lO>C=R8;|?N=AR@9WSN2fd51(O$8gGoC^^{PGwb zl@(g%v<6kZS*tx39~Ysrb+ssah5N~ZvIIjCmzKu9x5-3c2cFFgu?suw2V`gl6)=^q z;~C2tm~75ep~_&tX4-Ih!OgymE?k~sx{6alc+ZB#-39XjkSoI^P3$|~eCt{36>RB@^5+Sq5yMk+;S)n*gQww*d=SoLv^kx!MA1e46A8+YY=a)C6M^Z0c%XW*-A)?oM!#@=hq1JwOytlrdOvW ze4I)GJqy(v#3l4$S+tEyHZE*>tl_||J<^Nwdo-VYUKP)+{SZ8QEkQ*u;$iKn>Kt!B z8gT0T{Xtf5-ZNZ|Gjzjg%${J%BH(RX8&Uozru7Bn>V3eu-in)&isiQz=it!>O?}8@ z9fc9$nf)RxoJ_LA=ds-8C^_qw)4bB?k;$q_4J8L;&-hXK2Ko85bQ-pW(dK&<7fcGx zWv9rrP(Td?Ku$O0=dZQ~}6?Z$e&!{aG%Z`EqWDbWmL&b9^M~nR(HIN{qSqF+!A}CfxTEsGbN!;?- zlXN4Q+8uA@X5wflu>b00ZH$9IwYTgtDZ9+5%D9n7jX1@PmS&Ov%ouUCD@?&f9W{(P zFV9eyD(2=awY4IK#^5Hm)S*V2QLcL!`1<;K@@b~0BdWOgpXG~y$MAKkFQkBI_Bn&p zf=`8CPkNL#zCZSPKM#7(8W^G(6yDH8Rr1;yUy&7<;w=C6aGU!=xK5j(!spk^YxwODjgJj641ldNZF&)Q|y%Y(0#@8@kzLC@J)zS0I zb8!H|kG4OBW7M+oXe-Q6*k4lgXV5$4s}u9Ap7~IFn7^ByK}33XGz3_2!J3Wbbeh>b zBJd=Nm>mqm?Rp)QMDslG0=^y!Jdto#%SDb_QNNyvy)9OlIUNB^IWTl73D$0rl!A7& z9)7bd^OSbD$vq_Zu7H+y)L?iB7hExKXLA(AOUa&g6<021276itr5jNR8h(;zH}p0G zCWhut-RGS+Rz?nQyPyB+a!6Fm?2@KV_a{wYejzP!H6$duO=QA}0E6pp3XpeI`_c93 zMZ>~sDJx@Tp9!-sh~+{hhYP}tX~-jj&v$O>!LKf!#mQWd#@F)-NpxHg%t4Us#iO=> z7{FA3KN^Ocz&SfRuOS-}t~@Nv%p-8|*t0=qdntaR_PjaZ>)d3AVkEga>> zTh(mvHiP?IwEpb=iBr^!3VWUaZFPSwd3v(FEpZB9qn59`rvj6hJ0x<)686gc%~#T+ zS}1|mdN8{@D0xODU`?LMRW70qF#5*bQNX-`Cn24D>eZ$}4P}dFClB-KQ*tt7ZYX1AA3~xqIyoq09QszS1I#fW8XM!_uqx7&6}RxKV>I zGFqK9i^O-To47KR$v~!xT{4eqHq_Yk z+|MD;x?W0<2L&#z+BtTQ;he6w@R<%t7DeQT*vlq%jJLT+2JD7f7q40n=2JQWWc=CX z>PR=hIE}t_A}UYuyxa?+mGkY7K_P?zMvc5j&Y^@x>;Qf-LSI{2veY-9K80}PxGcRm z<`5lQVFcaY#;eWsKQtw-lC@#iXlaRxt$Xs)m6V2kO@h0fdfbcqaDE_S9qH_P?(nb` z(RA9uN_a=2GS{csXD()*!9ld&(z!m{w`T|FUC44;`CfS(J+{KkpEPUJA8YPKTyc+f zO$u*bN)Qy5$>{%!RbT|ucGQv@2&;PKo*L~tQ+Bbtedaftb~=Az&Kv|LCh=qz&iT@) z-E%DlFZRHdzeB+Kzol>}a8mAmo!kEB&-<1CeJA2~T4apS|44a4J{wtpDv4h%3D)!P^ zPg~WpXyf@Z-K{C@Tx+-7C1NG>Fj5!o##h4=2oq{{xR1Y`Eze4p%4@31Nd zryyN}p^5N#0jJK*Hd|!t=dXg@r5hvJPUIE{?-U=Z0U6tAQx(qru5(o9~P3a*Ym%qD;Pr&t- zCaf%?|KssHRqZkq*EjWth`_xnWL(#?3btS~S%`%5No8RKy~- zWN2H9+1GxbY$`Of!{GU|M@FA^?@>x%<;P)@8tt#wGWRO(Mzj=(e!L4Mvi-t%F4k#VS*b;3bERa_vL1Vgw2M~zfyJ>BtkZG2y-Mx9X0Qd+q|5) zwed~6#%y*)4gcFsyiY8G2x@-dv;uH3F?w9S6n{G9Vx*Ac69+aW79<7`p55}ciZiQJ z@Jq5h<}sm4FVu1T4d!%YgF(0>Y$taVfo~0|z=ga{7idYoi-Wr~KizGAR-3MDqaWrp zx8J%77oI-7mGm`xd`Fs3!&x&Q~Wjl^qwxf)iES5FM@)A`^)2iweDz6MuO&l#2m&3Gy~ z-OEQK((r0$##@5u{qNK2$wHVYf)^O;$vpeBBLj=S{+%u77^-O@5}KBXa2qw}XTExs zD016;j|hY{ZAm%tBrp@M=D8aWYepb3n0;z@*@F2@YRg1SEm+H{nHdHeClf?LT{$E? zV=(hOut=bVaW%cxVAYv*(zHDgv12j+PHp)Z>xWCfQ6#q7wI_)w2S!uplXyU z@^u(PEL%&NlSIxO5Rg#!RyZ3^K*nb(|cCeMAPrf!=-4fl~UNXI-ls2(X-h zL(eM^dmjh!W|CqP7sC391Z{<3yaR`l`HvneuP??Flb~PY$| z21VUtblEzC9|n6%7(Mb4wN9MpOmRBFVM3^3W5&!#(Yo@;bvSUJ9mu~&+TqL(oxy?y zMrdI37v}c&8~wg-F@_E6Yjt^_Fnib66Ltf=B{uddqzKyd^LCaJ2_>_uSrsRS1ow0> zzhw1=Fu=xOr+OMmVB6hhUmM_Ln1~p;+@1h<%|Uji@Uf|?xsuvG>AsIpLaQ07g4sTU0;mmk8!rV)O6_uIe3P-Uw8)!Z zz4J0!gbx67PpOiL4sk~CfRBk;?rG&319r6{CnpYAmWFmn%`WDmA3@BH0x*gTzSv*61HYyGRSz^Wgplsp z&_JKEzuWpSwLriTd-;If^Hxi4#WL*+I(t#(=1+L@TKrrC#4$Kicw5YAN`+t0w4=9@ zEpzZU|Ll*L-{Z8E>}K2?3Ax)M`pupX8^qG=Gy6# zfLpzdF2RmRGy+JnE>eHK^^%p(o}G%-p0e*`#622t66(JbkD2%p#sp$Eo04@S790Sz zkfO;#gyQ3fN!@LXBE?#y?vJ3E;qyD=5a3wBSNga?9*^}CJjlTx;`~ghx84PvU0Y6bnjEZKopi359;UwCGODY;j9LSni%o2H-u7+S zK2a15nWV+h|02$BQsv-BYa`gIPb*nHR`mIfr>3BkfD(|LXgPn@2th!Cd-vDCaTs@_g$ z8Y^}%Y@fG&IAuNvvw$mEVuJ-nm|zy*%9B|_m#y;;>wIxpfo~JUyK`e{>soZPeW|(G zhI(Vu$D*dCt~Kl_+J6f~EvdnR=QKJTrEh11qS|J}9OwI1K2hA(>()N&Xb4e6ic#a$ z!@i{Qw=42PCdd`eLdmvO^`69Ofp**5(@_<0@z3lq{m;WT(;t0D3mWCWfLJ40oA!Vn zh@5lBnypJ#LZ9aneHbrmw}UO|7?ne0#y{i<4{w%!x>) z;{(AhYQ$9&TFV#nr{Y&4oxVLIBco&pHe(}qD&*f4hm%bq8Hwq8bETtISmb9_ zX=!OqDY%xOT7bW5os7GiK|2;n(X~O#YKf-c<53@*MjXB18`Jpy#De65{y94a==nd7 zrT@Ma0HAeO@qF@iwVg>wuQB(|XT;0?y1`wI`oXCR90$Nep@h6C`RP}AeE}9ixpmp3 z_dkF8dyo|Vyx*bUJ$(k28Pv0i;jtKk@jo+1X>WYAUen7ys2f+2J)^=^84w4PW57qN#f5<4@v*{v8l zPP`>Y@*3P3Ce7pj96qk_dy@9p-zGYY?HMZI*n8($M*PS#Q~aX=m}03RVuE*Q4 zbo@jm4|9oBytUd#gpj5pZXwLE@TM6!A~uA}>1+jXST(&?u8@p*y-l?q5oJN|DSZIY z)3*iMbUW=cG_cmtlaZlkPNtY37X2X{vS`Pk`y={hRaM3$tg3?Dcy&|YL%Jcx9Kvu7JJEFPJl8ae>ZW029b+Ejj`alej4zaVO@7 zkoLkM!@4JC+uGVo)G1rN6GzE0kQg~pFX_r=I%ysm@-Tvb?w#mS{pH@kK<|7php|}Y zwozis(R`~D$41>yFSbhVdxn*OGiW)@O1BZ~kWQ)Lt#QfK}e?v_Y)MJW(7!)~9N9NJ`*Y8MFUY#v1ioesc5jJU?6Fhj(CgtT;-J}dRD^EC)MJr^Tx!cnnbMK2E1*v;9 zpI7ZCn^g&ZaDgHgEVTrD;y?D+Zz2+8XWvh6B03+Qf6?mQsZTea>pvNF3AlboWFSY< zuaxylr!hOCk@qN|Hw!?R)y=KM&JfD2g(T7zjG`eyXL+a!s$U)T^H$jv{rY)JsF5A&f(J1T z?ds-Po*q-J3G?ED(N~%XkvK{6mlY~j3V%%EC8aJoCZAMq@jt32N!?@KniAqN zxdXDE&nAhxx+1ir|9(&{v9nV!jJZL9>oDS{m}|NOU|e;}aPo!BM(u`PAcYDB%^ zKip0s#cTtn==*=roXS#dXF7)hBi)FjT}IE-qR)*yZU!@@^(M}3y@%&~f5-R_do)pP z>EH2^yY(|;(ek9W(&e3T?!qgQ$WU=Kx`xQS?>}JFZ@^ywNlA$f_Pj&QKZlRix~q-aTCj+N{=ur0l;WN{B>xv9c6mf6_z&fFaFg--Umh%m^O5?0Z_@LqDp@b- zMQI8a#JD=#Kl6tbTeIg%Gp?<%v+E!DoW>>1yytD55dj1^TeeQx2en<%YN8$e6PUmB znILoj_s_9h9O^Fx)To*(NB?72rJl(D74|GDO6K?FO}hoBajz(V$QAo|?S-S*xxYa`d literal 0 HcmV?d00001