Skip to content

Commit fb91ddd

Browse files
Rewrite action with javascript
1 parent 73c1489 commit fb91ddd

File tree

3 files changed

+117
-63
lines changed

3 files changed

+117
-63
lines changed

README.md

+29-16
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,43 @@
11
# hackage-publish
2+
23
A GitHub action for publishing packages and documentation to Hackage
34

4-
## Usage Examples
5+
## Inputs
6+
7+
* `hackageToken` (required): An auth token from Hackage, which can be generated at `https://hackage.haskell.org/user/$USERNAME/manage`
8+
9+
* `candidate` (optional): Whether to upload as a package candidate or not.
10+
* 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`.
11+
12+
* `packagesPath` (optional): The path that contains package tarballs (defaults to `dist-newstyle/sdist/`)
13+
14+
* `docsPath` (optional): The path that contains packages' documentation tarballs.
15+
* If not set, does not upload documentation separately
16+
17+
* `hackageServer` (optional): The URL of the Hackage server to upload to (e.g. for self-hosted Hackage instances)
518

6-
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).
19+
## Outputs
20+
21+
N/A
22+
23+
## Example
724

825
```yaml
926
- uses: haskell-actions/hackage-publish@v1
1027
with:
1128
hackageToken: ${{ secrets.HACKAGE_AUTH_TOKEN }}
12-
packagesPath: ${{ runner.temp }}/packages
13-
docsPath: ${{ runner.temp }}/docs
14-
publish: false
1529
```
1630
17-
`docsPath` parameter is optional and the action will not try uploading documentation when it is not specified.
18-
When `docsPath` is specified, but doesn't contain documentation for one or many packages in `packagePath`,
19-
these packages are uploaded without documentation. Missing documentation never results into an execution error.
20-
21-
To publish to a custom/private Hackage, specify `hackageServer` parameter to the custom/private Hackage server URI
22-
31+
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.
32+
2333
```yaml
34+
- name: Load Hackage token secret name
35+
id: hackage_token_secret
36+
run: |
37+
USERNAME="$(echo "${GITHUB_ACTOR}" | tr '[:lower:]' '[:upper:]' | tr '-' '_')"
38+
echo "name=HACKAGE_TOKEN_${USERNAME}" >> "${GITHUB_OUTPUT}"
39+
2440
- uses: haskell-actions/hackage-publish@v1
2541
with:
26-
hackageServer: ${{ secrets.HACKAGE_SERVER }}
27-
hackageToken: ${{ secrets.HACKAGE_AUTH_TOKEN }}
28-
packagesPath: ${{ runner.temp }}/packages
29-
publish: true
30-
```
42+
hackageToken: ${{ secrets[steps.hackage_token_secret.outputs.name] }}
43+
```

action.yml

+20-47
Original file line numberDiff line numberDiff line change
@@ -15,63 +15,36 @@ inputs:
1515
description: 'Authentication token for Hackage'
1616
required: true
1717

18-
publish:
19-
description: 'A flag indicating whether to publish the release on Hackage. Uploads a release candidate if set to false'
18+
candidate:
19+
description: Whether to upload the package as a candidate
2020
required: false
21-
default: 'false'
21+
default: 'true'
2222

2323
packagesPath:
24-
description: 'Path that contains packages tarbals'
24+
description: 'Path that contains package tarballs'
2525
required: false
2626
default: dist-newstyle/sdist/
2727

2828
docsPath:
29-
description: 'Path that contains packages documentation tarbals'
29+
description: 'Path that contains packages documentation tarballs'
3030
required: false
3131
default: ''
3232

3333
runs:
34-
using: 'composite'
34+
using: composite
3535
steps:
36-
37-
- name: Publish packages
36+
###########################################################################
37+
# remove when actions/github-script uses node 18 (which has FormData globally)
38+
# https://github.com/actions/github-script/issues/310
39+
- uses: actions/setup-node@v3
40+
with: { node-version: 18 }
41+
- run: npm install formdata-node@4
3842
shell: bash
39-
run: |
40-
HACKAGE_AUTH_HEADER="Authorization: X-ApiKey ${{ inputs.hackageToken }}"
41-
42-
for PACKAGE_TARBALL in $(find "${{ inputs.packagesPath }}" -maxdepth 1 -name "*.tar.gz"); do
43-
PACKAGE_NAME=$(basename ${PACKAGE_TARBALL%.*.*})
44-
45-
if [ "${{ inputs.publish }}" == "true" ];
46-
then
47-
TARGET_URL="${{ inputs.hackageServer }}/packages/upload";
48-
PACKAGE_URL="${{ inputs.hackageServer }}/package/$PACKAGE_NAME"
49-
HACKAGE_STATUS=$(curl --header "${HACKAGE_AUTH_HEADER}" --silent --head -w %{http_code} -XGET --anyauth ${{ inputs.hackageServer }}/package/${PACKAGE_NAME} -o /dev/null)
50-
else
51-
TARGET_URL="${{ inputs.hackageServer }}/packages/candidates";
52-
PACKAGE_URL="${{ inputs.hackageServer }}/package/$PACKAGE_NAME/candidate"
53-
HACKAGE_STATUS=404
54-
fi
55-
56-
DOCS_URL="$PACKAGE_URL/docs"
57-
58-
if [ "$HACKAGE_STATUS" = "404" ]; then
59-
echo "Uploading ${PACKAGE_NAME} to ${TARGET_URL}"
60-
curl -X POST -f --header "${HACKAGE_AUTH_HEADER}" ${TARGET_URL} -F "package=@$PACKAGE_TARBALL"
61-
echo "Uploaded ${PACKAGE_URL}"
62-
63-
DOC_FILE_NAME="${{ inputs.docsPath }}/${PACKAGE_NAME}-docs.tar.gz"
64-
if [ -n "${{ inputs.docsPath }}" ] && [ -f "$DOC_FILE_NAME" ]; then
65-
echo "Uploading documentation for ${PACKAGE_NAME} to ${DOCS_URL}"
66-
curl -X PUT \
67-
-H 'Content-Type: application/x-tar' \
68-
-H 'Content-Encoding: gzip' \
69-
-H "${HACKAGE_AUTH_HEADER}" \
70-
--data-binary "@$DOC_FILE_NAME" \
71-
"$DOCS_URL"
72-
fi
73-
74-
else
75-
echo "Package ${PACKAGE_NAME}" already exists on Hackage.
76-
fi
77-
done
43+
###########################################################################
44+
- uses: actions/github-script@v6
45+
with:
46+
script: |
47+
// https://github.com/actions/toolkit/issues/1124#issuecomment-1305836110
48+
const inputs = ${{ toJSON(inputs) }}
49+
const script = require('${{ github.action_path }}/upload-hackage.js')
50+
await script({ inputs, core, glob, fetch, require })

upload-hackage.js

+68
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
module.exports = async ({ inputs, core, glob, fetch, require }) => {
2+
const fs = require('fs')
3+
const path = require('path')
4+
5+
// remove when using Node 18
6+
// https://github.com/actions/github-script/issues/310
7+
const { FormData, Blob, File } = require('formdata-node')
8+
9+
const auth = `X-ApiKey ${inputs.hackageToken}`
10+
const uploadUrlSuffix = inputs.candidate ? '/candidates' : '/upload'
11+
const packageUrlSuffix = inputs.candidate ? '/candidate' : ''
12+
const checkExists = !inputs.candidate
13+
14+
const archives = await glob.create(`${inputs.packagesPath}/*.tar.gz`).then((g) => g.glob())
15+
for (const archive of archives) {
16+
const name = path.basename(archive, '.tar.gz')
17+
const uploadUrl = `${inputs.hackageServer}/packages${uploadUrlSuffix}`
18+
const packageUrl = `${inputs.hackageServer}/packages/${name}${packageUrlSuffix}`
19+
20+
// check if package already uploaded
21+
if (checkExists) {
22+
const existsResp = await fetch(packageUrl, { method: 'HEAD' })
23+
if (existsResp.status != 404) {
24+
core.info(`Package "${name}" already exists on Hackage`)
25+
continue
26+
}
27+
}
28+
29+
// upload package
30+
core.info(`Uploading package "${name}" to ${uploadUrl}`)
31+
const uploadBody = new FormData()
32+
uploadBody.set(
33+
'package',
34+
new File(
35+
[new Blob([fs.readFileSync(archive)], { type: 'application/octet-stream' })],
36+
archive,
37+
),
38+
)
39+
const uploadResp = await fetch(uploadUrl, {
40+
method: 'POST',
41+
headers: { 'Authorization': auth },
42+
body: uploadBody,
43+
})
44+
if (uploadResp.status >= 400) {
45+
const uploadRespBody = await uploadResp.text()
46+
throw new Error(`Failed to upload package "${name}": ${uploadRespBody}`)
47+
}
48+
core.info(`Successfully uploaded ${packageUrl}`)
49+
50+
// upload docs
51+
const docsArchive = `${inputs.docsPath}/${name}-docs.tar.gz`
52+
const docsUrl = `${packageUrl}/docs`
53+
if (inputs.docsPath && fs.existsSync(docsArchive)) {
54+
core.info(`Uploading documentation for "${name}" to ${docsUrl}`)
55+
const docsBody = await fetch.blobFrom(docsArchive, 'application/octet-stream')
56+
await fetch(docsUrl, {
57+
method: 'PUT',
58+
headers: {
59+
'Content-Type': 'application/x-tar',
60+
'Content-Encoding': 'gzip',
61+
'Authorization': auth,
62+
},
63+
body: docsBody,
64+
})
65+
core.info(`Successfully uploaded ${docsUrl}`)
66+
}
67+
}
68+
}

0 commit comments

Comments
 (0)