# Validate -> sign -> self-verify -> commit the signature back to main. # # The index is served straight out of this repository over Gitea's anonymous # raw endpoint, so "publishing" IS committing v1/index.json.sig to main. There # is no separate static host and no deploy step. # # https://git.unom.io/unom/punktfunk-plugin-index/raw/branch/main/v1/index.json # https://git.unom.io/unom/punktfunk-plugin-index/raw/branch/main/v1/index.json.sig # # This workflow holds INDEX_SIGNING_KEY, the private half of the key the host # pins. It therefore only ever runs on `main` (post-merge) or by explicit # dispatch -- never on a pull request. PR validation lives in validate.yml, # which has no access to the key. That split is the point: a fork PR can change # index.json all it likes, but nothing it does gets signed until a human merges. name: publish run-name: ${{ gitea.actor }} sign plugin index on: push: branches: [main] # LOOP GUARD (primary -- this is the one relied on). This job's own output # is v1/index.json.sig, and it commits that file back to main, which would # retrigger the job. `paths-ignore` means "run unless EVERY changed path # matches", so the bot's signature-only commit is skipped, while a push # touching index.json (alone, or together with the signature) still runs. # # Do not add a `paths:` include list alongside this -- `paths` and # `paths-ignore` are mutually exclusive for a single event. paths-ignore: - "v1/index.json.sig" workflow_dispatch: jobs: publish: runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - uses: actions/checkout@v4 with: ref: main # The token must be able to push to main. Gitea injects GITEA_TOKEN # automatically, which is sufficient for an unprotected branch. If # main is protected, set INDEX_BOT_TOKEN to a PAT permitted to push # through the protection; it takes precedence when present. token: ${{ secrets.INDEX_BOT_TOKEN || secrets.GITEA_TOKEN }} - uses: oven-sh/setup-bun@v2 with: bun-version: latest # Full validation INCLUDING the live registry cross-check. If a pinned # hash no longer matches upstream, we must not sign it. - name: Validate index run: bun run validate - name: Sign index env: INDEX_SIGNING_KEY: ${{ secrets.INDEX_SIGNING_KEY }} run: | set -euo pipefail if [ -z "${INDEX_SIGNING_KEY:-}" ]; then echo "::error::INDEX_SIGNING_KEY secret is not set; refusing to publish an unsigned index" exit 1 fi # The key reaches the tool via env only -- never a file in the # workspace, never an argument (which would show up in process lists). bun run sign # Self-check: re-verify what we just produced, with the same code path an # auditor would use. Catches a key/host mismatch BEFORE it ships, rather # than as every host in the field silently rejecting the catalog. - name: Verify signature run: bun run verify -- --pub "${{ vars.INDEX_PUBLIC_KEY || 'ed25519:qK18TE2qygIyObtMlHxUI/G1gKby8tPxuieAfnEKgYE=' }}" - name: Commit signature to main run: | set -euo pipefail # ed25519 is deterministic: the same key over the same bytes yields a # byte-identical signature. So a push that did not change index.json # (a README edit, a tooling change) re-signs to exactly what is # already committed and there is nothing to commit -- no churn, no # empty commits, and no loop even before the guards take effect. if [ -z "$(git status --porcelain -- v1/index.json.sig)" ]; then echo "signature unchanged -- nothing to commit" exit 0 fi git config user.name "unom-bot" git config user.email "bot@unom.io" git add v1/index.json.sig # LOOP GUARD (secondary, belt-and-braces). Gitea's runner honours # skip-ci markers in the commit subject -- SKIP_WORKFLOW_STRINGS in # app.ini, which defaults to [skip ci],[ci skip],[no ci], # [skip actions],[actions skip]. The paths-ignore filter above is the # mechanism actually relied on, because it holds even where an # operator has narrowed SKIP_WORKFLOW_STRINGS. This marker is a # second line of defence and a signal to humans reading the log. git commit -m "chore(index): sign v1/index.json [skip ci]" \ -m "Signature over ${GITEA_SHA:-$GITHUB_SHA}. Generated by the publish workflow; do not edit by hand." git push origin HEAD:main - name: Summary run: | { echo "### Plugin index signed and published" echo echo "- plugins: $(bun -e 'console.log(JSON.parse(require("fs").readFileSync("v1/index.json","utf8")).plugins.length)')" echo "- advisories: $(bun -e 'console.log(JSON.parse(require("fs").readFileSync("v1/index.json","utf8")).security.length)')" echo echo "Served from:" echo '```' echo "https://git.unom.io/unom/punktfunk-plugin-index/raw/branch/main/v1/index.json" echo "https://git.unom.io/unom/punktfunk-plugin-index/raw/branch/main/v1/index.json.sig" echo '```' } >> "$GITEA_STEP_SUMMARY"