From ff50cc139caad693bb0a7659a4138ebeeb7aa658 Mon Sep 17 00:00:00 2001 From: Brandon Chinn Date: Wed, 5 Apr 2023 00:37:52 -0700 Subject: [PATCH] Rewrite action with javascript --- README.md | 45 ++++++++++++++++++---------- action.yml | 75 ++++++++++++++++++----------------------------- upload-hackage.js | 59 +++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 63 deletions(-) create mode 100644 upload-hackage.js diff --git a/README.md b/README.md index d02ac81..93798c5 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,43 @@ # hackage-publish + A GitHub action for publishing packages and documentation to Hackage -## Usage Examples +## Inputs + +* `hackageToken` (required): An auth token from Hackage, which can be generated at `https://hackage.haskell.org/user/$USERNAME/manage` + +* `candidate` (optional): Whether to upload as a package candidate or not. + * Defaults to `true` because Hackage uploads are permanent, and it's usually not a good idea to do irreversible actions in an automatic pipeline. But if you absolutely want to skip the candidate step, set this to `false`. + +* `packagesPath` (optional): The path that contains package tarballs (defaults to `dist-newstyle/sdist/`) + +* `docsPath` (optional): The path that contains packages' documentation tarballs. + * If not set, does not upload documentation separately + +* `hackageServer` (optional): The URL of the Hackage server to upload to (e.g. for self-hosted Hackage instances) -This step publishes packages and documentation as candidates on Hackage using the specified authentication token. You can generate an authentication token on [your Hackage account managment page](http://hackage.haskell.org/users/account-management). +## Outputs + +N/A + +## Example ```yaml - uses: haskell-actions/hackage-publish@v1 with: hackageToken: ${{ secrets.HACKAGE_AUTH_TOKEN }} - packagesPath: ${{ runner.temp }}/packages - docsPath: ${{ runner.temp }}/docs - publish: false ``` -`docsPath` parameter is optional and the action will not try uploading documentation when it is not specified. -When `docsPath` is specified, but doesn't contain documentation for one or many packages in `packagePath`, -these packages are uploaded without documentation. Missing documentation never results into an execution error. - -To publish to a custom/private Hackage, specify `hackageServer` parameter to the custom/private Hackage server URI - +One particularly useful workflow is to be able to use the hackage token associated with the GitHub user running the workflow. This allows multiple maintainers to use their own tokens and not have to share one user's token. + ```yaml +- name: Load Hackage token secret name + id: hackage_token_secret + run: | + USERNAME="$(echo "${GITHUB_ACTOR}" | tr '[:lower:]' '[:upper:]' | tr '-' '_')" + echo "name=HACKAGE_TOKEN_${USERNAME}" >> "${GITHUB_OUTPUT}" + - uses: haskell-actions/hackage-publish@v1 with: - hackageServer: ${{ secrets.HACKAGE_SERVER }} - hackageToken: ${{ secrets.HACKAGE_AUTH_TOKEN }} - packagesPath: ${{ runner.temp }}/packages - publish: true -``` + hackageToken: ${{ secrets[steps.hackage_token_secret.outputs.name] }} +``` \ No newline at end of file diff --git a/action.yml b/action.yml index 9981009..46ebf2b 100644 --- a/action.yml +++ b/action.yml @@ -15,63 +15,44 @@ inputs: description: 'Authentication token for Hackage' required: true - publish: - description: 'A flag indicating whether to publish the release on Hackage. Uploads a release candidate if set to false' + candidate: + description: Whether to upload the package as a candidate required: false - default: 'false' + default: 'true' packagesPath: - description: 'Path that contains packages tarbals' + description: 'Path that contains package tarballs' required: false default: dist-newstyle/sdist/ docsPath: - description: 'Path that contains packages documentation tarbals' + description: 'Path that contains packages documentation tarballs' required: false default: '' runs: - using: 'composite' + using: composite steps: - - - name: Publish packages + # reuse built-in FormData when `actions/github-script` uses node 18? + # https://github.com/actions/github-script/issues/310 + # https://nodejs.org/dist/latest-v18.x/docs/api/globals.html#class-formdata + - + uses: actions/setup-node@v3 + with: { node-version: 16 } + - + run: npm install node-fetch + working-directory: ${{ github.action_path }} shell: bash - run: | - HACKAGE_AUTH_HEADER="Authorization: X-ApiKey ${{ inputs.hackageToken }}" - - for PACKAGE_TARBALL in $(find "${{ inputs.packagesPath }}" -maxdepth 1 -name "*.tar.gz"); do - PACKAGE_NAME=$(basename ${PACKAGE_TARBALL%.*.*}) - - if [ "${{ inputs.publish }}" == "true" ]; - then - TARGET_URL="${{ inputs.hackageServer }}/packages/upload"; - PACKAGE_URL="${{ inputs.hackageServer }}/package/$PACKAGE_NAME" - HACKAGE_STATUS=$(curl --header "${HACKAGE_AUTH_HEADER}" --silent --head -w %{http_code} -XGET --anyauth ${{ inputs.hackageServer }}/package/${PACKAGE_NAME} -o /dev/null) - else - TARGET_URL="${{ inputs.hackageServer }}/packages/candidates"; - PACKAGE_URL="${{ inputs.hackageServer }}/package/$PACKAGE_NAME/candidate" - HACKAGE_STATUS=404 - fi - - DOCS_URL="$PACKAGE_URL/docs" - - if [ "$HACKAGE_STATUS" = "404" ]; then - echo "Uploading ${PACKAGE_NAME} to ${TARGET_URL}" - curl -X POST -f --header "${HACKAGE_AUTH_HEADER}" ${TARGET_URL} -F "package=@$PACKAGE_TARBALL" - echo "Uploaded ${PACKAGE_URL}" - - DOC_FILE_NAME="${{ inputs.docsPath }}/${PACKAGE_NAME}-docs.tar.gz" - if [ -n "${{ inputs.docsPath }}" ] && [ -f "$DOC_FILE_NAME" ]; then - echo "Uploading documentation for ${PACKAGE_NAME} to ${DOCS_URL}" - curl -X PUT \ - -H 'Content-Type: application/x-tar' \ - -H 'Content-Encoding: gzip' \ - -H "${HACKAGE_AUTH_HEADER}" \ - --data-binary "@$DOC_FILE_NAME" \ - "$DOCS_URL" - fi - - else - echo "Package ${PACKAGE_NAME}" already exists on Hackage. - fi - done + - + uses: actions/github-script@v6 + with: + script: | + const fetchModule = await import( + '${{ github.action_path }}/node_modules/node-fetch/src/index.js' + ) + const { FormData, fileFrom } = fetchModule + + // https://github.com/actions/toolkit/issues/1124#issuecomment-1305836110 + const inputs = ${{ toJSON(inputs) }} + const script = require('${{ github.action_path }}/upload-hackage.js') + await script({ inputs, core, glob, fetch: fetchModule.default, require, FormData, fileFrom }) diff --git a/upload-hackage.js b/upload-hackage.js new file mode 100644 index 0000000..d8e7737 --- /dev/null +++ b/upload-hackage.js @@ -0,0 +1,59 @@ +module.exports = async ({ inputs, core, glob, fetch, require, FormData, fileFrom }) => { + const fs = require('fs') + const path = require('path') + + const auth = `X-ApiKey ${inputs.hackageToken}` + const uploadUrlSuffix = inputs.candidate ? '/candidates' : '/upload' + const packageUrlSuffix = inputs.candidate ? '/candidate' : '' + const checkExists = !inputs.candidate + + const archivePaths = await glob.create(`${inputs.packagesPath}/*.tar.gz`).then((g) => g.glob()) + for (const archivePath of archivePaths) { + const name = path.basename(archivePath, '.tar.gz') + const uploadUrl = `${inputs.hackageServer}/packages${uploadUrlSuffix}` + const packageUrl = `${inputs.hackageServer}/packages/${name}${packageUrlSuffix}` + + // check if package already uploaded + if (checkExists) { + const existsResp = await fetch(packageUrl, { method: 'HEAD' }) + if (existsResp.status != 404) { + core.info(`Package "${name}" already exists on Hackage`) + continue + } + } + + // upload package + core.info(`Uploading package "${name}" to ${uploadUrl}`) + const uploadBody = new FormData() + const archiveFile = await fileFrom(archivePath, 'application/octet-stream') + uploadBody.set('package', archiveFile) + const uploadResp = await fetch(uploadUrl, { + method: 'POST', + headers: { 'Authorization': auth }, + body: uploadBody, + }) + if (uploadResp.status >= 400) { + const uploadRespBody = await uploadResp.text() + throw new Error(`Failed to upload package "${name}": ${uploadRespBody}`) + } + core.info(`Successfully uploaded ${packageUrl}`) + + // upload docs + const docsArchive = `${inputs.docsPath}/${name}-docs.tar.gz` + const docsUrl = `${packageUrl}/docs` + if (inputs.docsPath && fs.existsSync(docsArchive)) { + core.info(`Uploading documentation for "${name}" to ${docsUrl}`) + const docsBody = await fetch.blobFrom(docsArchive, 'application/octet-stream') + await fetch(docsUrl, { + method: 'PUT', + headers: { + 'Content-Type': 'application/x-tar', + 'Content-Encoding': 'gzip', + 'Authorization': auth, + }, + body: docsBody, + }) + core.info(`Successfully uploaded ${docsUrl}`) + } + } +}