A collection of GitHub Actions for managing a Posit Connect extension gallery.
These actions handle linting, releasing, and generating a gallery index
(extensions.json) from extension manifests and GitHub Releases.
For extensions that can be packaged directly by creating a tarball of their
directory, you just need a directory containing all of the extensions (e.g.
./extensions/), and a gallery.json file at the root of your repo that list
the categories your extensions fit in (see below for the format of that file).
name: Publish Extension Workflow
on:
pull_request:
push:
branches:
- main
permissions:
contents: write
jobs:
# Lint, package, and release each extension
extensions:
strategy:
fail-fast: false
matrix:
extension:
- my-extension
- another-extension
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
- uses: posit-dev/connect-gallery-action/build-extension@main
with:
extension-name: ${{ matrix.extension }}
# Regenerate extensions.json and commit
update-gallery:
runs-on: ubuntu-latest
needs: [extensions]
if: ${{ always() }}
steps:
- uses: actions/checkout@v4
with:
ref: main
- uses: posit-dev/connect-gallery-action@main
with:
extensions-dir: extensionsAnd then, to use this in a Connect instance, you'll add the following to the config file.
For both the URL and Repo, change those values to point to your GitHub org and repo.
[GalleryFeed "My feed"]
URL = "https://raw.githubusercontent.com/{org}/{repo}/refs/heads/main/extensions.json"
Name = "My Extensions"
Description = "Internal extensions"
Repo = "https://github.com/{org}/{repo}"
This repository provides five composite actions. build-extension is a bundle of
lint-extension, package-extension and release-extension. They are exposed
separately if you need more complex setups (see the advanced section below)
| Action | Description |
|---|---|
posit-dev/connect-gallery-action@main |
Generates extensions.json from extension manifests and GitHub Releases |
posit-dev/connect-gallery-action/build-extension@main |
Lints, packages, and optionally releases an extension in a single step |
↳ posit-dev/connect-gallery-action/lint-extension@main |
Validates an extension manifest for required fields and structure |
↳ posit-dev/connect-gallery-action/package-extension@main |
Packages an extension into a tarball and uploads it as a workflow artifact |
↳ posit-dev/connect-gallery-action/release-extension@main |
Creates a GitHub Release for a single extension when its version is bumped |
To use these actions you need a repository with the following structure:
your-repo/
extensions/
my-extension/
manifest.json
...
another-extension/
manifest.json
...
gallery.json
Defines the categories available in the gallery:
{
"categories": [
{
"id": "extension",
"title": "extensions",
"description": "Useful tools that extend the functionality of Connect."
},
{
"id": "example",
"title": "examples",
"description": "Pre-built content to illustrate publishable content types."
}
]
}Each extension directory must contain a manifest.json with an extension
section. This is in addition to the standard manifest fields generated by
rsconnect or rsconnect-python.
{
"extension": {
"name": "my-extension",
"title": "My Extension",
"description": "A description of the extension.",
"homepage": "https://github.com/your-org/your-repo/tree/main/extensions/my-extension",
"category": "extension",
"tags": ["python"],
"minimumConnectVersion": "2025.04.0",
"requiredFeatures": [],
"version": "0.0.0"
},
"environment": {
"python": {
"requires": "~=3.8"
}
}
}Required fields:
name-- Must match the extension's directory nametitle-- Display name in the gallerydescription-- Description shown in the galleryhomepage-- Link to documentation or sourceminimumConnectVersion-- Minimum Posit Connect version requiredversion-- Semantic version (see Versioning)
Optional fields:
category-- Must match a categoryidfromgallery.jsontags-- Array of tags for filtering in the galleryrequiredFeatures-- Array of Connect features required (e.g.,["API Publishing"])
Environment section:
The environment section specifies language version constraints using the ~=
(compatible release) operator:
{
"environment": {
"python": { "requires": "~=3.8" },
"r": { "requires": "~=4.2" },
"quarto": { "requires": "~=1.3" }
}
}Extensions use semantic versioning. The version in manifest.json controls
releases:
0.0.0-- Reserved. Will never be released. Use this during development.- Any other valid semver -- A GitHub Release is created when the version in
manifest.jsonis greater than the latest released version and the change is merged tomain.
Scans extension manifests and GitHub Releases to generate extensions.json.
- uses: posit-dev/connect-gallery-action@main
with:
extensions-dir: extensions
gallery-config: gallery.json| Input | Required | Default | Description |
|---|---|---|---|
extensions-dir |
No | extensions |
Relative path to the extensions directory |
gallery-config |
No | gallery.json |
Relative path to the gallery config file |
commit |
No | true |
Whether to commit and push extensions.json when updates are generated |
| Output | Description |
|---|---|
has-updates |
true if extensions.json was generated (at least one released extension exists) |
Lints, packages, and optionally releases an extension in a single step. This
is a convenience wrapper around lint-extension, package-extension, and
release-extension.
- uses: posit-dev/connect-gallery-action/build-extension@main
with:
extension-name: my-extensionTo skip the release step (e.g., on pull requests where you only want to lint and package):
- uses: posit-dev/connect-gallery-action/build-extension@main
with:
extension-name: my-extension
release: "false"| Input | Required | Default | Description |
|---|---|---|---|
extension-name |
Yes | The name of the extension (must match its directory name) | |
artifact-name |
No | Name of an uploaded artifact to use as the tarball source. If omitted, the extension directory is tarred directly. | |
release |
No | "true" |
Whether to run the release step |
extensions-dir |
No | "extensions" |
Relative path from workspace root to extensions directory |
Validates that an extension manifest has all required fields, proper structure, and a valid semver version.
- uses: posit-dev/connect-gallery-action/lint-extension@main
with:
extension-name: my-extension| Input | Required | Default | Description |
|---|---|---|---|
extension-name |
Yes | The name of the extension (must match its directory name) | |
extensions-dir |
No | "extensions" |
Relative path from workspace root to extensions directory |
- Extension name matches directory name and
nameinmanifest.json - All required fields are present and correctly typed
requiredFeaturesandtagsare arrays (if present)- Version is valid semver
Packages an extension into a {extension-name}.tar.gz tarball and uploads it
as a workflow artifact for use by subsequent jobs (e.g., testing or release).
For simple extensions (where the directory can be tarred as-is):
- uses: posit-dev/connect-gallery-action/package-extension@main
with:
extension-name: my-extensionFor custom-built extensions, first upload an artifact containing only the
runtime files, then pass its name via artifact-name. The action will download
that artifact and create the tarball from it instead of the source directory:
- uses: posit-dev/connect-gallery-action/package-extension@main
with:
extension-name: my-extension
artifact-name: my-extension| Input | Required | Default | Description |
|---|---|---|---|
extension-name |
Yes | The name of the extension (must match its directory name) | |
artifact-name |
No | Name of an uploaded artifact to use as the tarball source. If omitted, the extension directory is tarred directly. | |
extensions-dir |
No | "extensions" |
Relative path from workspace root to extensions directory |
Creates a GitHub Release when an extension's manifest version is bumped. The release includes the packaged extension as an asset and manifest metadata in the release body.
- uses: posit-dev/connect-gallery-action/release-extension@main
with:
extension-name: my-extensionRequires GH_TOKEN to be set in the environment for gh CLI access.
The release tag follows the format {extension-name}@v{version} (e.g.,
my-extension@v1.0.0). This action expects a {extension-name}.tar.gz
artifact to have been uploaded by a previous step in the workflow.
| Input | Required | Default | Description |
|---|---|---|---|
extension-name |
Yes | The name of the extension | |
extensions-dir |
No | "extensions" |
Relative path from workspace root to extensions directory |
For extensions that can be packaged directly by creating a tarball of their directory, use a single workflow that handles change detection, linting, packaging, testing, releasing, and gallery generation.
name: Extension Workflow
on:
pull_request:
push:
branches:
- main
permissions:
contents: write
jobs:
# Detect which extensions changed
extension-changes:
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
changes: ${{ steps.changes.outputs.changes }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: changes
with:
filters: |
my-extension: extensions/my-extension/**
another-extension: extensions/another-extension/**
# Lint, package, and release each changed extension
extensions:
needs: [extension-changes]
if: ${{ needs.extension-changes.outputs.changes != '[]' }}
strategy:
fail-fast: false
matrix:
extension: ${{ fromJSON(needs.extension-changes.outputs.changes) }}
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
- uses: posit-dev/connect-gallery-action/build-extension@main
with:
extension-name: ${{ matrix.extension }}
# Regenerate extensions.json and commit
update-gallery:
runs-on: ubuntu-latest
needs: [extensions]
if: ${{ always() }}
steps:
- uses: actions/checkout@v4
with:
ref: main
- uses: posit-dev/connect-gallery-action@main
with:
extensions-dir: extensions-
Lint --
lint-extensionvalidates the manifest structure before any packaging or release steps run. -
Package --
package-extensionpackages the extension into a{name}.tar.gztarball and uploads it as a workflow artifact. -
Release --
release-extensioncompares the manifest version against the latest GitHub Release tag ({name}@v{version}). If the manifest version is strictly greater and the workflow is running onmain, it creates a new GitHub Release with the tarball as an asset and manifest metadata in the release body. -
Generate -- The main action scans all extension manifests, queries all GitHub Releases (up to 1000), and assembles
extensions.jsoncontaining categories, tags, required features, and all extension versions sorted by semver.
Extensions that require build steps (e.g., compiling TypeScript, bundling assets) before packaging need a custom workflow. The key difference is uploading only the built artifacts, then using a packaging step that creates the tarball from those artifacts rather than the full source directory.
name: My Custom Extension
on:
workflow_call:
env:
EXTENSION_NAME: my-custom-extension
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
jobs:
extension:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./extensions/${{ env.EXTENSION_NAME }}
steps:
- uses: actions/checkout@v4
- uses: posit-dev/connect-gallery-action/lint-extension@main
with:
extension-name: ${{ env.EXTENSION_NAME }}
# Custom build steps
- uses: actions/setup-node@v4
with:
node-version: "lts/*"
- run: npm ci
- run: npm run build
# Upload only the files needed at runtime
- uses: actions/upload-artifact@v4
with:
name: ${{ env.EXTENSION_NAME }}
path: |
extensions/${{ env.EXTENSION_NAME }}/dist/
extensions/${{ env.EXTENSION_NAME }}/requirements.txt
extensions/${{ env.EXTENSION_NAME }}/app.py
extensions/${{ env.EXTENSION_NAME }}/manifest.json
# Package the uploaded artifact into a tarball
- uses: posit-dev/connect-gallery-action/package-extension@main
with:
extension-name: ${{ env.EXTENSION_NAME }}
artifact-name: ${{ env.EXTENSION_NAME }}
release:
runs-on: ubuntu-latest
needs: [extension]
steps:
- uses: actions/checkout@v4
- uses: posit-dev/connect-gallery-action/release-extension@main
with:
extension-name: ${{ env.EXTENSION_NAME }}This custom workflow is then called from the main extensions workflow:
my-custom-extension:
needs: [complex-extension-changes]
if: ${{ needs.complex-extension-changes.outputs.my-custom-extension == 'true' }}
uses: ./.github/workflows/my-custom-extension.yml
secrets: inheritThe gallery generation logic lives in scripts/:
cd scripts
npm ci
npm test # Run tests
npm run generate-gallery # Build and run the generatorTests run automatically on push and pull requests to main via
.github/workflows/ci.yml.