name: Build images on: push: branches: [dev, main] # Requires repo secret RELEASE_TOKEN — a Forgejo PAT with scopes: # - write:package, read:package (for docker push to git.fabledsword.com) # - write:release (for ext- release asset cache) # - write:issue (for future issue-management automation) # The injected GITHUB_TOKEN cannot be used — it lacks write:package. jobs: # Sign-or-fetch-from-cache: signs the extension via AMO if no ext- # Forgejo release exists yet, otherwise downloads the cached signed XPI. # Result is uploaded as an Actions artifact for build-web to consume. # # Why this lives in build.yml (not a separate workflow): the merge-commit's # docker image tagged `:latest` MUST carry the XPI. A separate sign workflow # racing build.yml leaves `:latest` without the XPI for ~5min (until the # commit-back triggers another build). Inline ordering eliminates the race. # Cache strategy: Forgejo Release Assets — picked 2026-05-25 over Generic # Packages (cleaner API surface) and commit-back-to-side-branch (no extra # branch to manage). AMO blocks re-signing the same version (returns 409), # so signing is intentionally one-shot per version bump. sign-extension: if: github.ref == 'refs/heads/main' runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 steps: - uses: actions/checkout@v4 - name: Resolve extension version id: extver run: | VERSION=$(grep -E '"version"' extension/package.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/') echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Resolved extension version: $VERSION" - name: Check Forgejo release-asset cache id: cache env: TOKEN: ${{ secrets.RELEASE_TOKEN }} run: | set -eu VERSION=${{ steps.extver.outputs.version }} STATUS=$(curl -s -o release.json -w "%{http_code}" \ -H "Authorization: token $TOKEN" \ "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/tags/ext-$VERSION" || echo 000) echo "Tag lookup HTTP status: $STATUS" if [ "$STATUS" = "200" ]; then ASSET_ID=$(jq -r '.assets[]? | select(.name | test("\\.xpi$")) | .id' release.json | head -1) if [ -n "$ASSET_ID" ] && [ "$ASSET_ID" != "null" ]; then echo "cached=true" >> "$GITHUB_OUTPUT" echo "asset_id=$ASSET_ID" >> "$GITHUB_OUTPUT" echo "Cached XPI exists at ext-$VERSION (asset id $ASSET_ID); skipping AMO sign" else echo "cached=false" >> "$GITHUB_OUTPUT" echo "Release ext-$VERSION exists but has no .xpi asset; will re-sign + re-upload" fi else echo "cached=false" >> "$GITHUB_OUTPUT" echo "No release named ext-$VERSION; will sign via AMO and upload" fi - name: Download cached signed XPI (cache hit) if: steps.cache.outputs.cached == 'true' env: TOKEN: ${{ secrets.RELEASE_TOKEN }} run: | set -eu mkdir -p extension/web-ext-artifacts curl -sL -H "Authorization: token $TOKEN" \ -o "extension/web-ext-artifacts/fabledcurator-${{ steps.extver.outputs.version }}.xpi" \ "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/assets/${{ steps.cache.outputs.asset_id }}" ls -la extension/web-ext-artifacts/ - name: Sign via AMO (cache miss) if: steps.cache.outputs.cached != 'true' run: | cd extension && npm install --no-save --no-audit --no-fund && npm run sign env: WEB_EXT_API_KEY: ${{ secrets.MOZILLA_AMO_JWT_KEY }} WEB_EXT_API_SECRET: ${{ secrets.MOZILLA_AMO_JWT_SECRET }} - name: Upload signed XPI to ext- release (cache miss) if: steps.cache.outputs.cached != 'true' env: TOKEN: ${{ secrets.RELEASE_TOKEN }} run: | set -eux VERSION=${{ steps.extver.outputs.version }} # AMO renames signed XPIs with its internal addon-id-safe-string; # canonicalize to fabledcurator-.xpi so the FC server's # whitelist (backend/app/frontend.py expects 'fabledcurator-*.xpi') # keeps working. SIGNED=$(ls extension/web-ext-artifacts/*.xpi | head -1) XPI="extension/web-ext-artifacts/fabledcurator-$VERSION.xpi" cp "$SIGNED" "$XPI" # Find-or-create the ext- release. STATUS=$(curl -s -o release.json -w "%{http_code}" \ -H "Authorization: token $TOKEN" \ "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/tags/ext-$VERSION" || echo 000) if [ "$STATUS" != "200" ]; then curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ -d "{\"tag_name\":\"ext-$VERSION\",\"name\":\"Extension $VERSION (signed XPI cache)\",\"body\":\"Internal cache for the signed XPI consumed by build.yml's build-web job. Not a user-facing FC release.\",\"target_commitish\":\"main\"}" \ -o release.json \ "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases" fi RELEASE_ID=$(jq -r '.id' release.json) test -n "$RELEASE_ID" && test "$RELEASE_ID" != "null" curl -s -X POST -H "Authorization: token $TOKEN" \ -F "attachment=@$XPI" \ "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/$RELEASE_ID/assets?name=fabledcurator-$VERSION.xpi" echo "Uploaded fabledcurator-$VERSION.xpi to ext-$VERSION release" - name: Upload XPI as Actions artifact (handoff to build-web) uses: actions/upload-artifact@v4 with: name: signed-extension path: extension/web-ext-artifacts/fabledcurator-*.xpi retention-days: 1 build-web: needs: [sign-extension] # sign-extension is main-only; on dev it's skipped, build-web still runs. if: always() && (needs.sign-extension.result == 'success' || needs.sign-extension.result == 'skipped') runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 steps: - uses: actions/checkout@v4 - name: Download signed extension (main only) if: github.ref == 'refs/heads/main' uses: actions/download-artifact@v4 with: name: signed-extension path: extension/web-ext-artifacts/ - name: Place signed XPI in build context (main only) if: github.ref == 'refs/heads/main' run: | set -eux VERSION=$(grep -E '"version"' extension/package.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/') XPI=$(ls extension/web-ext-artifacts/*.xpi | head -1) mkdir -p frontend/public/extension cp "$XPI" "frontend/public/extension/fabledcurator-$VERSION.xpi" cp "$XPI" "frontend/public/extension/fabledcurator-latest.xpi" ls -la frontend/public/extension/ - name: Determine tag id: tag run: | if [ "${GITHUB_REF##*/}" = "main" ]; then echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:main,git.fabledsword.com/bvandeusen/fabledcurator:latest" >> "$GITHUB_OUTPUT" else echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT" fi - name: Login to Forgejo registry uses: docker/login-action@v3 with: registry: git.fabledsword.com username: ${{ github.actor }} password: ${{ secrets.RELEASE_TOKEN }} - name: Build and push web image uses: docker/build-push-action@v5 with: context: . file: Dockerfile push: true tags: ${{ steps.tag.outputs.tags }} build-ml: runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 steps: - uses: actions/checkout@v4 - name: Determine tag id: tag run: | if [ "${GITHUB_REF##*/}" = "main" ]; then echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:main,git.fabledsword.com/bvandeusen/fabledcurator-ml:latest" >> "$GITHUB_OUTPUT" else echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:dev" >> "$GITHUB_OUTPUT" fi - name: Login to Forgejo registry uses: docker/login-action@v3 with: registry: git.fabledsword.com username: ${{ github.actor }} password: ${{ secrets.RELEASE_TOKEN }} - name: Build and push ml image uses: docker/build-push-action@v5 with: context: . file: Dockerfile.ml push: true tags: ${{ steps.tag.outputs.tags }}