Managing multiple feeds with 0repo¶
Once you have more than one feed, the per-repo 0template + 0publish workflow from the previous tutorial starts to chafe. Each app needs its own GPG key copy, its own gh-pages branch, its own merge logic. There's no shared catalog, no shared key, no consistent enforcement of policies like "every feed must declare a license" or "release dates must be set".
0repo replaces the per-repo plumbing with a single, central feed repository. It:
- Validates each incoming feed against your repository's policies (license required, release date set, signed by an authorized key, ...).
- Merges new implementations into the right main feed.
- Signs the result with the repository's GPG key.
- Generates a top-level catalog and a directory listing.
The key shift from the previous tutorial: each app's *.xml.template keeps living in the app's own source repo (alongside the source code). The app's CI builds the release, runs 0template, attaches the per-version feed to a GitHub Release, and tells the central feed repo to pick it up. The central repo runs 0repo and publishes.
This tutorial assumes you've worked through Automating a single feed with CI, so the source-repo side will look familiar.
By the end you will have:
- A central
feedsrepository with0repo-config.pyonmainand signed feeds ongh-pages. Served athttps://YOURNAME.github.io/feeds/. - One or more app source repositories, each with its own
*.xml.templateand a CI workflow that releases through the central repo. - An
Incomingworkflow on the central repo that runs0repowhenever an app finishes a release.
1. Create the central feed repository¶
Create a new repo feeds (any name works). Initialise an empty gh-pages orphan branch and push it:
git clone https://github.com/YOURNAME/feeds.git
cd feeds
git checkout --orphan gh-pages
git rm -rf .
echo "" > archives.db
git add archives.db
git commit -m "Initial gh-pages"
git push -u origin gh-pages
git checkout main
In Settings → Pages, set the source to Deploy from a branch / gh-pages / root. Note the URL; let's say https://YOURNAME.github.io/feeds/. This is your repository base URL.
2. Initialise 0repo¶
0install add 0repo https://apps.0install.net/0install/0repo.xml
0repo create ~/repos/feeds 'Your Name'
0repo create writes a 0repo-config.py. Move it into your Git checkout and edit REPOSITORY_BASE_URL and GPG_SIGNING_KEY:
REPOSITORY_BASE_URL = "https://YOURNAME.github.io/feeds/"
GPG_SIGNING_KEY = None if os.getenv('NO_SIGN') else "0xYOURKEYFINGERPRINT"
The NO_SIGN escape hatch lets CI run 0repo in dry-run mode for pull-request validation without exposing the production key.
Commit 0repo-config.py to main and push.
3. Add the GPG key as a repository secret¶
Export the private key:
gpg --export-secret-keys --armor YOURKEY
In the feeds repo's Settings → Secrets and variables → Actions, create a secret named GPG_KEY with the armored key as its value.
4. Add publish & incoming workflows¶
Two workflows on the feeds repo do the heavy lifting:
publish.ymlruns whenever0repo-config.pychanges onmain(so policy edits take effect without a feed change).incoming.ymlis a manually triggered workflow that pulls a fresh feed from a URL, runs0repoto merge and sign it, and pushesgh-pages. App repos kick this off with the0repo-submitaction.
Both use the Zero Install GitHub Actions: 0repo-setup prepares the working directory and 0repo runs the tool.
Note that neither workflow starts with actions/checkout. 0repo needs main and gh-pages checked out side by side rather than at the workspace root, so 0repo-setup performs both checkouts itself and creates the surrounding structure:
feeds/ # main: 0repo-config.py, the unsigned feeds and templates
public/ # gh-pages: the published (signed) feeds and archives.db
incoming/ # empty, holds feeds waiting to be merged
0repo-config.py # symlink to feeds/0repo-config.py
archives.db # symlink to public/archives.db
This mirrors the layout you set up by hand for local runs in step 6. 0repo commits its results into both checkouts, and the GitHub Action optionally pushes them back when it is done.
.github/workflows/publish.yml:
name: Publish
on:
workflow_dispatch: {}
push:
branches: [main]
paths: ['0repo-config.py']
concurrency: { group: publish }
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: 0install/github-actions/0repo-setup@v1
- uses: 0install/github-actions/0repo@v1
with:
gpg-key: ${{ secrets.GPG_KEY }}
push-public: true
.github/workflows/incoming.yml:
name: Incoming
on:
workflow_dispatch:
inputs:
feed_url:
required: true
description: URL of the per-version feed to merge in
archive_url:
required: false
description: URL of the archive to register in archives.db
concurrency: { group: publish }
jobs:
incoming:
runs-on: ubuntu-latest
steps:
- uses: 0install/github-actions/0repo-setup@v1
- uses: 0install/github-actions/0repo@v1
with:
gpg-key: ${{ secrets.GPG_KEY }}
incoming-feed-url: ${{ inputs.feed_url }}
incoming-archive-url: ${{ inputs.archive_url }}
push-feeds: true
push-public: true
Given an incoming-feed-url, the 0repo action downloads the feed into incoming/ before running the tool, and if the archive is hosted outside the repository it registers its name, hash and URL in archives.db. 0repo then validates the feed, merges it into the master feed, signs everything and regenerates the catalog.
Leaving out gpg-key makes the 0repo action set the NO_SIGN environment variable, which is why 0repo-config.py reads it. Leave out the push-* inputs as well and you have a third workflow that validates pull requests without access to the production key and without changing anything:
name: Verify
on: pull_request
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: 0install/github-actions/0repo-setup@v1
- uses: 0install/github-actions/0repo@v1
As an extra safeguard, public/ is never pushed when gpg-key is unset, even if push-public is true: an unsigned run must not replace the signed feeds.
The apps.0install.net workflows are a real-world deployment of exactly these three workflows, and worth reading when you scale up.
5. Wire up an app's source repo¶
Each app keeps its own template and source code together. Concretely, in the app's repo (say myapp):
myapp/
├── .github/
│ └── workflows/
│ └── build.yml
├── src/
├── build.sh
├── myapp.xml.template
└── README.md
The template's <feed-for> points at the central repo, not at the app's own GitHub Pages:
<feed-for interface="https://YOURNAME.github.io/feeds/myapp.xml"/>
The build workflow at myapp/.github/workflows/build.yml:
name: Build
on:
push:
tags: ['v*']
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Determine version
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Build release archive
run: ./build.sh ${{ steps.version.outputs.version }}
- name: Generate per-version feed
id: feed
uses: 0install/github-actions/0template@v1
with:
template: myapp.xml.template
version: ${{ steps.version.outputs.version }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v3
with:
files: |
${{ steps.feed.outputs.feed }}
${{ steps.feed.outputs.archive }}
- name: Submit feed to central repository
uses: 0install/github-actions/0repo-submit@v1
with:
repository: YOURNAME/feeds
feed-url: ${{ steps.feed.outputs.feed }}
archive-url: ${{ steps.feed.outputs.archive }}
token: ${{ secrets.PERSONAL_TOKEN }}
The paths that 0template produced are attached to the GitHub Release and then handed to 0repo-submit as-is: a relative path is resolved against the Release for the current tag, so you don't have to spell the download URL out twice.
Note that the feed itself keeps the relative href from the template; it is archive-url that tells the central repository where the archive actually lives. 0repo records that in archives.db and rewrites the href when it generates the published feed.
PERSONAL_TOKEN is a fine-grained personal access token with Actions: write on the feeds repo. The default GITHUB_TOKEN only has permissions on the current repo, so it can't trigger workflows elsewhere. Store it as a secret in the app's repo.
The flow is:
- You push tag
v1.2on themyapprepo. - CI builds the archive and runs
0template myapp.xml.template version=1.2, producingmyapp-1.2.xml. - CI uploads both the feed and the archive as release assets.
- CI calls
gh workflow runon thefeedsrepo, passing the URLs of both. - The
Incomingworkflow onfeedsdownloads them, runs0repo, signs, and pushesgh-pages.
A few minutes later, https://YOURNAME.github.io/feeds/myapp.xml carries the new version. Existing users pick it up the next time their cache becomes stale.
This is exactly the pattern 0capture and 0template use to publish into apps.0install.net.
6. Local previewing¶
For changes to 0repo-config.py itself, or for hand-staged feeds, you can still run 0repo on your laptop. Tell 0repo where the working copy lives:
cd ~/Code/feeds
ln -s feeds/0repo-config.py .
ln -s public/archives.db .
mkdir incoming
0repo register
Then drop a per-version feed into incoming/ and run:
0repo
To preview the result without pushing:
0repo proxy
# in another terminal:
http_proxy=http://localhost:8080/ 0install run https://YOURNAME.github.io/feeds/myapp.xml
What's next¶
- A catalog of third-party feeds: when the upstream is someone else, you replace the source-repo CI with a
*.watch.pyscript that polls for new releases. - 0repo's README covers multi-developer setups, archive hosting, and the rest of
0repo-config.py.