diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..c23f0dc --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +open_collective: ponyc diff --git a/.github/linters/.markdown-lint.yml b/.github/linters/.markdown-lint.yml new file mode 100644 index 0000000..67d2ae5 --- /dev/null +++ b/.github/linters/.markdown-lint.yml @@ -0,0 +1,3 @@ +{ + "MD013": false +} diff --git a/.github/workflows/add-discuss-during-sync.yml b/.github/workflows/add-discuss-during-sync.yml new file mode 100644 index 0000000..46d3f13 --- /dev/null +++ b/.github/workflows/add-discuss-during-sync.yml @@ -0,0 +1,29 @@ +name: Add discuss during sync label + +on: + issues: + types: + - opened + - reopened + issue_comment: + types: + - created + pull_request_target: + types: + - opened + - edited + - ready_for_review + - reopened + pull_request_review: + types: + - submitted + +jobs: + add-label: + runs-on: ubuntu-latest + steps: + - name: Add "discuss during sync" label to active GH entity + uses: andymckay/labeler@467347716a3bdbca7f277cb6cd5fa9c5205c5412 + with: + repo-token: ${{ secrets.PONYLANG_MAIN_API_TOKEN }} + add-labels: "discuss during sync" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..9800ad8 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,64 @@ +name: PR + +on: pull_request + +jobs: + lint-entrypoint-py: + name: Lint entrypoint.py + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: pylint + run: make pylint + + superlinter: + name: Lint bash, docker, markdown, and yaml + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Lint codebase + uses: docker://github/super-linter:v3.8.3 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VALIDATE_ALL_CODEBASE: true + VALIDATE_BASH: true + VALIDATE_DOCKERFILE: true + VALIDATE_MD: true + VALIDATE_YAML: true + + validate-public-release-image-builds: + name: Validate public release image builds + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Docker build + run: make build-release config=public + + validate-public-latest-image-builds: + name: Validate public latest image builds + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Docker build + run: make build-latest config=public + + validate-private-release-image-builds: + name: Validate private release image builds + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Docker build + run: make build-release config=private + env: + MATERIAL_INSIDERS_ACCESS: ${{ secrets.MATERIAL_INSIDERS_ACCESS }} + + validate-private-latest-image-builds: + name: Validate private latest image builds + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Docker build + run: make build-latest config=private + env: + MATERIAL_INSIDERS_ACCESS: ${{ secrets.MATERIAL_INSIDERS_ACCESS }} + diff --git a/.github/workflows/remove-discuss-during-sync.yml b/.github/workflows/remove-discuss-during-sync.yml new file mode 100644 index 0000000..c4d7d6d --- /dev/null +++ b/.github/workflows/remove-discuss-during-sync.yml @@ -0,0 +1,19 @@ +name: Remove discuss during sync label + +on: + issues: + types: + - closed + pull_request_target: + types: + - closed + +jobs: + remove-label: + runs-on: ubuntu-latest + steps: + - name: Remove label + uses: andymckay/labeler@467347716a3bdbca7f277cb6cd5fa9c5205c5412 + with: + repo-token: ${{ secrets.PONYLANG_MAIN_API_TOKEN }} + remove-labels: "discuss during sync" diff --git a/.github/workflows/update-latest-image.yml b/.github/workflows/update-latest-image.yml new file mode 100644 index 0000000..eec9735 --- /dev/null +++ b/.github/workflows/update-latest-image.yml @@ -0,0 +1,69 @@ +name: Update latest images + +on: + repository_dispatch: + types: + - ponyc-musl-nightly-released + workflow_dispatch: + +concurrency: + group: "rebuild-latest-images" + cancel-in-progress: true + +jobs: + rebuild-public-latest-image: + name: Rebuild latest public image + runs-on: ubuntu-latest + steps: + - name: Checkout source + uses: actions/checkout@v3 + - name: Login to Docker Hub + run: "docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD" + env: + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + - name: Build + run: make build-latest config=public + - name: Push + run: make push-latest config=public + - name: Send alert on failure + if: ${{ failure() }} + uses: zulip/github-actions-zulip@35d7ad8e98444f894dcfe1d4e17332581d28ebeb + with: + api-key: ${{ secrets.ZULIP_SCHEDULED_JOB_FAILURE_API_KEY }} + email: ${{ secrets.ZULIP_SCHEDULED_JOB_FAILURE_EMAIL }} + organization-url: 'https://ponylang.zulipchat.com/' + to: notifications + type: stream + topic: ${{ github.repository }} scheduled job failure + content: ${{ github.server_url}}/${{ github.repository }}/actions/runs/${{ github.run_id }} failed. + + rebuild-private-latest-image: + name: Rebuild latest private image + runs-on: ubuntu-latest + steps: + - name: Checkout source + uses: actions/checkout@v3 + - name: Login to GitHub Container Registry + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build + run: make build-latest config=private + env: + MATERIAL_INSIDERS_ACCESS: ${{ secrets.MATERIAL_INSIDERS_ACCESS }} + - name: Push + run: make push-latest config=private + - name: Send alert on failure + if: ${{ failure() }} + uses: zulip/github-actions-zulip@35d7ad8e98444f894dcfe1d4e17332581d28ebeb + with: + api-key: ${{ secrets.ZULIP_SCHEDULED_JOB_FAILURE_API_KEY }} + email: ${{ secrets.ZULIP_SCHEDULED_JOB_FAILURE_EMAIL }} + organization-url: 'https://ponylang.zulipchat.com/' + to: notifications + type: stream + topic: ${{ github.repository }} scheduled job failure + content: ${{ github.server_url}}/${{ github.repository }}/actions/runs/${{ github.run_id }} failed. diff --git a/.markdownlintignore b/.markdownlintignore new file mode 100644 index 0000000..bca6033 --- /dev/null +++ b/.markdownlintignore @@ -0,0 +1,3 @@ +CHANGELOG.md +CODE_OF_CONDUCT.md +.release-notes/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fb3d39c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +ARG FROM_TAG=release-alpine +FROM ponylang/ponyc:${FROM_TAG} + +ARG PACKAGE + +RUN apk add --update --no-cache \ + bash \ + libffi \ + libffi-dev \ + libressl \ + libressl-dev \ + make \ + python3 \ + python3-dev \ + py3-pip \ + tar + +RUN pip3 install --upgrade pip \ + gitpython \ + in_place \ + mkdocs \ + ${PACKAGE} \ + pylint \ + pyyaml + +COPY entrypoint.py /entrypoint.py + +ENTRYPOINT ["/entrypoint.py"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a8e32a2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,25 @@ +BSD 2-Clause License + +Copyright (c) 2022, The Pony Developers +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0bc66bd --- /dev/null +++ b/Makefile @@ -0,0 +1,40 @@ +config ?= public + +ifdef config + ifeq (,$(filter $(config),public private)) + $(error Unknown configuration "$(config)") + endif +endif + +ifeq ($(config),private) + IMAGE := ghcr.io/ponylang/library-documentation-action-v2 + PACKAGE = "git+https://${MATERIAL_INSIDERS_ACCESS}@github.com/squidfunk/mkdocs-material-insiders.git" +else + IMAGE = ponylang/library-documentation-action-v2 + PACKAGE = "mkdocs-material" +endif + +all: pylint + +build: + docker build --pull --build-arg PACKAGE="${PACKAGE}" --build-arg FROM_TAG="${version}-alpine" -t "${IMAGE}:${version}" . + +build-latest: + docker build --pull --build-arg PACKAGE="${PACKAGE}" --build-arg FROM_TAG="alpine" -t "${IMAGE}:latest" . + +build-release: + docker build --pull --build-arg PACKAGE="${PACKAGE}" --build-arg FROM_TAG="release-alpine" -t "${IMAGE}:release" . + +push: build + docker push "${IMAGE}:${version}" + +push-latest: build-latest + docker push "${IMAGE}:latest" + +push-release: build-release + docker push "${IMAGE}:release" + +pylint: build-latest + docker run --entrypoint pylint --rm "${IMAGE}:latest" /entrypoint.py + +.PHONY: build pylint diff --git a/README.md b/README.md index 14b02c3..14707a4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,103 @@ # library-documentation-action-v2 -Generates documentation for Pony libraries + +A GitHub Action that generates documentation for a Pony library and updates that documentation on GitHub pages. The library in question must have a Makefile with a target `docs` that can be used to generate the documentation that can be feed to `mkdocs`. + +Generated docs can be uploaded for hosting anywhere you like. The examples below show them being hosted on GitHub pages. + +You need to supply the url of your site to the action in the `site_url` option. For GitHub pages, that domain will be `https://USER_OR_ORG_NAME.github.io/REPOSITORY_NAME/`. + +## Example workflow + +In **release.yaml**, in addition the usual [release-bot-action](https://github.com/ponylang/release-bot-action) workflow entries. + +```yml +name: Release + +on: + push: + tags: + - \d+.\d+.\d+ + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "update-documentation" + cancel-in-progress: true + +jobs: + generate-documentation: + name: Generate documentation for release + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Generate documentation + uses: ponylang/library-documentation-action@via-github-action + with: + site_url: "https://MYORG.github.io/MYLIBRARY/" + library_name: "MYLIBRARY" + docs_build_dir: "build/MY-LIBRARY-docs" + - name: Setup Pages + uses: actions/configure-pages@v2 + - name: Upload artifact + uses: actions/upload-pages-artifact@v1 + with: + path: 'build/MY-LIBRARY-docs/site/' + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v1 +``` + +## Manually triggering a documentation build and deploy + +GitHub has a [`workflow_dispatch`](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#workflow_dispatch) event that provides a button the actions UI to trigger the workflow. You can set up a workflow to respond to a workflow_dispatch if you need to regenerate documentation from the last commit on a given branch without doing a full release. + +We suggest that you install the a `workflow_dispatch` driven workflow to generate documentation the when you first install this action so you don't need to do a superfluous release. + +```yml +name: Manually generate documentation + +on: + workflow_dispatch + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "update-documentation" + cancel-in-progress: true + +jobs: + generate-documentation: + name: Generate documentation for release + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Generate documentation + uses: ponylang/library-documentation-action@via-github-action + with: + site_url: "https://MYORG.github.io/MYLIBRARY/" + library_name: "MYLIBRARY" + docs_build_dir: "build/MY-LIBRARY-docs" + - name: Setup Pages + uses: actions/configure-pages@v2 + - name: Upload artifact + uses: actions/upload-pages-artifact@v1 + with: + path: 'build/MY-LIBRARY-docs/site/' + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v1 +``` diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md new file mode 100644 index 0000000..84af85c --- /dev/null +++ b/RELEASE_PROCESS.md @@ -0,0 +1,9 @@ +# How to cut a library-documentation-action release + +This document is aimed at members of the team who might be cutting a release of library-documentation-action . It serves as a checklist that can take you through doing a release step-by-step. + +## Releasing + +There's no release process library-documentation-action. New `release` and `latest` docker images are created each time a new nightly or release version of ponyc is created. + +We used to have a release process for the library documentation action, but each time there was a breaking change in ponyc, we had to create a new version of the library-documentation-action and every user had to update the version they were using. Now, on each release a new version is created. diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..f49f480 --- /dev/null +++ b/action.yml @@ -0,0 +1,16 @@ +description: Generates documentation for Pony libraries +inputs: + docs_build_dir: + description: Location, relative to the Makefile, that generated documentation + will be placed + required: true + library_name: + description: Name of the library being uploaded + required: true + site_url: + description: Url for the site that the documentation will be hosted on + required: true +name: Library Documentation action +runs: + image: Dockerfile + using: docker diff --git a/entrypoint.py b/entrypoint.py new file mode 100755 index 0000000..a69207e --- /dev/null +++ b/entrypoint.py @@ -0,0 +1,233 @@ +#!/usr/bin/python3 +# pylint: disable=C0103 +# pylint: disable=C0114 + +import json +import os +import os.path +import shutil +import sys + +import in_place +import yaml + +# Output strings for coloring + +ENDC = '\033[0m' +ERROR = '\033[31m' +INFO = '\033[34m' +NOTICE = '\033[33m' + +library_name = os.environ['INPUT_LIBRARY_NAME'] +docs_build_dir = os.environ['INPUT_DOCS_BUILD_DIR'] + +# +# make the documentation +# + +print(INFO + "Running 'make docs'." + ENDC) +rslt = os.system('make docs') +if rslt != 0: + print(ERROR + "'make docs' failed." + ENDC) + sys.exit(1) + +mkdocs_yml_file = os.path.join(docs_build_dir, 'mkdocs.yml') +docs_dir = os.path.join(docs_build_dir, 'docs') +index_file = os.path.join(docs_dir, 'index.md') +source_dir = os.path.join(docs_dir, 'src') + +# +# remove any docs that aren't part of this library +# store information about removed entries so we can fix up links to them later +# + +print(INFO + "Removing 'other docs'." + ENDC) +removed_docs = [] +for f in os.listdir(docs_dir): + if f in ('assets', 'src', 'index.md'): + continue + + if not f.startswith(library_name + '-'): + p = os.path.join(docs_dir, f) + if os.path.isfile(p): + os.remove(p) + else: + shutil.rmtree(p) + removed_docs.append(f) + +# +# remove any source code that isn't part of this library +# + +print(INFO + "Removing 'other sources'." + ENDC) +for f in os.listdir(source_dir): + if f != library_name: + p = os.path.join(source_dir, f) + if os.path.isfile(p): + os.remove(p) + else: + shutil.rmtree(p) + +# +# - trim mkdocs.yml down to entries for our library +# record those packages for later reference +# + +print(INFO + "Trimming mkdocs.yml." + ENDC) +mkdocs_yml = {} +packages = [] +with open(mkdocs_yml_file, encoding="utf8") as infile: + mkdocs_yml = yaml.load(infile, Loader=yaml.FullLoader) + nav = mkdocs_yml['nav'] + + new_nav = [] + library_package_key = 'package ' + library_name + library_subpackage_key = 'package ' + library_name + '/' + for entry in nav: + # there's only 1 entry but we don't know what it is because + # well that's how the yaml package represents this thing should be a + # 2-element tuple or list + for k in entry.keys(): + if k == library_name: + # library index entry. keep it. + new_nav.append(entry) + + if k == library_package_key \ + or k.startswith(library_subpackage_key): + # package entry. keep it. + # record the package name for later usage + new_nav.append(entry) + # entry will look like one of: + # package semver + # package semver/subpackage + packages.append(k[8:]) + + mkdocs_yml['nav'] = new_nav + +# add a site url to fix some /asset links +# without this, the 404 page will be broken +mkdocs_yml['site_url'] = os.environ['INPUT_SITE_URL'] + +with open(mkdocs_yml_file, 'w', encoding="utf8") as outfile: + yaml.dump(mkdocs_yml, outfile) + +# +# trim docs/index.md down to entries for our library +# + +print(INFO + "Trimming index.md." + ENDC) +with in_place.InPlace(index_file) as fp: + for line in fp: + if not line.startswith('*'): + fp.write(line) + else: + for p in packages: + if line.startswith('* [' + p + ']'): + fp.write(line) + +# +# `make docs` at the start will have pulled down any needed dependencies that +# we might have. Here we are going to reach into the _corral directory to find +# the `corral.json` for any dependencies and get: +# - the package names +# - the location of the documentation_url +# +# This should eventually be incorporated into `corral` as a command +# or something similar. In the meantime, we are doing "by hand" in this +# action as we work out how to accomplish everything that we want to. +# +# This could grab info about "extra" packages as there is on guarantee that a +# dependency that was removed isn't still in _corral directory assuming that +# this code was used outside of the context of this action that starts from a +# clean-slate. That's not an edge condition to worry about at this time. +# +# packages provided are listed in `corral.json` in an array with the key +# `packages`. Every package needs to be listed including those that are +# "subpackages" so for example, we have package listings for `semver`, +# `semver/constraint`, and `semver/version`. +# +# The documentation_url for a given package is located in the `info` object +# in the `documentation_url` field. +# + +documentation_urls = {} + +if os.path.isdir("_corral"): + dependencies_dirs = os.listdir("_corral") + for dd in dependencies_dirs: + corral_file = "/".join(["_corral", dd, "corral.json"]) + if not os.path.isfile(corral_file): + print(NOTICE + "No corral.json in " + dd + "." + ENDC) + continue + + with open(corral_file, 'r', encoding="utf8") as corral_fd: + corral_data = json.load(corral_fd) + bundle_documentation_url = "" + try: + bundle_documentation_url = corral_data['info']['documentation_url'] + except KeyError as e: + print(NOTICE + "No documentation_url in " + corral_file + "." \ + + ENDC) + + try: + packages = corral_data['packages'] + for p in packages: + documentation_urls[p] = bundle_documentation_url + except KeyError as e: + print(NOTICE + "No packages in " + corral_file + "." \ + + ENDC) + +# +# Go through the markdown belonging to our package and replace missing entries +# with links to their external sites. +# + +print(INFO + "Fixing links to code outside of our package." + ENDC) +for f in os.listdir(docs_dir): + if f in ('assets', 'src'): + continue + + p = os.path.join(docs_dir, f) + print(INFO + "Fixing links in " + str(p) + "." + ENDC) + with in_place.InPlace(p) as fp: + for line in fp: + for removed in removed_docs: + if removed in line: + print(INFO + "Replacing link for " + removed + "." + ENDC) + + # get the package name + s = removed.replace('.md', '') + s = s.split('-') + if len(s) > 1: + del s[-1] + package_name = '/'.join(s) + + # if unknown package, we'll use the standard library + external_url = documentation_urls.get(package_name, \ + 'https://stdlib.ponylang.io/') + + # as the external url is input from users, it might not + # include a trailing slash. if not, generated urls will + # be broken. + # there's far more validation we could do here, but in + # terms of helping out a non-malicious user, this is the + # minimum + if not external_url.endswith('/'): + external_url += '/' + + as_html = removed.replace('.md', '') + link = external_url + as_html + "/" + line = line.replace(removed, link) + + fp.write(line) + +# +# run mkdocs to actually build the content +# + +print(INFO + "Running 'mkdocs build'." + ENDC) +os.chdir(docs_build_dir) +rslt = os.system('mkdocs build') +if rslt != 0: + print(ERROR + "'mkdocs build' failed." + ENDC) + sys.exit(1)