Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rewrite action with github-script #8

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 29 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -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] }}
```
75 changes: 28 additions & 47 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
59 changes: 59 additions & 0 deletions upload-hackage.js
Original file line number Diff line number Diff line change
@@ -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}`)
}
}
}