name: Release # Tag a version to cut a release: # # git tag v1.0.0 && git push origin v1.0.0 # # A prerelease MUST be "-beta.N", dotted — v0.1.3-beta.1, never v0.1.3-beta1, and no longer # "-rc.N". Two separate rules of electron-updater's GitHub provider force that exact shape, and # neither is visible from the outside: # # Dotted, because the update channel is `semver.prerelease(version)[0]`. Dotted gives "beta" for # every release in the train; glued gives "beta1", "beta2", … so each release becomes its own # channel and no install is ever offered the next one. That is not theoretical — it is why # v0.1.0-rc2 through rc16 never updated anyone. The job below refuses the glued form. # # "beta" (or "alpha"), because the provider only considers a FINAL release when the running # channel is one of null / "alpha" / "beta" — those three names are hard-coded in its matching # loop. On any other identifier, "rc" included, a build never sees a stable release at all. That # is why v0.1.2 could not reach a single install of v0.1.1-rc.N: they were all on a channel the # provider treats as private to itself. # # Mind the ordering when changing the shape: prerelease identifiers compare as strings, so moving # between forms has to bump the base version too (0.1.2 -> 0.1.3-beta.1). # # Manual runs build the installers and leave them as artifacts without publishing anything, which # is how to check a packaging change without spending a version number on it. on: push: tags: - "v*" workflow_dispatch: # Needed to create the release and upload assets to it. permissions: contents: write env: # Must match the major version of embedded-postgres-binaries.version in apps/backend/pom.xml. # pgvector is compiled against these server headers, and a library built for one major version # will not load into another. PG_MAJOR: "17" # The full version, for the Windows binaries download. Keep both in step with # embedded-postgres-binaries.version in apps/backend/pom.xml. PG_FULL: "17.5" PGVECTOR_VERSION: "0.8.0" jobs: installers: name: ${{ matrix.name }} runs-on: ${{ matrix.os }} strategy: # One platform failing should not throw away the other's build — a Linux packaging problem # is no reason to be left without a Windows installer to test. fail-fast: false matrix: include: # The latest*.yml feeds and the .blockmaps ship WITH the installers, not as an extra: # electron-updater reads latest.yml to learn a newer version exists and diffs against # the blockmap to download only what changed. Releases used to carry only the # installers, so every installed app checked for updates against a feed that was not # there — auto-update existed and never updated anyone. - os: windows-latest name: Windows installer artifact: | apps/desktop/release/*.exe apps/desktop/release/*.exe.blockmap apps/desktop/release/latest.yml - os: ubuntu-latest name: Linux installers artifact: | apps/desktop/release/*.AppImage apps/desktop/release/*.deb apps/desktop/release/latest-linux.yml # Two mac runners, one per architecture, because nothing in this build cross-compiles: # the jar's PostgreSQL binaries, the pgvector dylib and the jlink runtime are all native # to the machine that builds them. The zip rides along with the dmg because Squirrel.Mac # — what electron-updater drives on macOS — updates from a zip; a dmg-only release would # install fine and never update. Each runner writes a latest-mac.yml that names only its # own arch; the publish job merges the two before anything is uploaded. - os: macos-latest name: macOS installer (arm64) artifact: | apps/desktop/release/*.dmg apps/desktop/release/*.zip apps/desktop/release/*.blockmap apps/desktop/release/latest-mac.yml - os: macos-15-intel name: macOS installer (x64) artifact: | apps/desktop/release/*.dmg apps/desktop/release/*.zip apps/desktop/release/*.blockmap apps/desktop/release/latest-mac.yml steps: - uses: actions/checkout@v5 # Fails the release before anything is built, because the alternative is an installer that # looks fine and updates nobody — a defect only visible months later, in other people's # installs. See the note at the top of this file. - name: Refuse a glued prerelease tag if: startsWith(github.ref, 'refs/tags/v') shell: bash run: | tag="${GITHUB_REF_NAME}" if [[ "$tag" =~ -[A-Za-z]+[0-9]+$ ]]; then echo "::error::Tag $tag glues its prerelease to a number, which gives every release its" echo "::error::own electron-updater channel and breaks auto-update. Use a dot:" echo "::error:: ${tag%%-*}-beta.N" exit 1 fi # And the identifier itself has to be one the provider will let a build leave. See the # note at the top: on any other name, an install never sees a final release again. if [[ "$tag" == *-* ]]; then id="${tag#*-}" id="${id%%.*}" if [[ "$id" != "beta" && "$id" != "alpha" ]]; then echo "::error::Tag $tag uses the prerelease identifier '$id'. electron-updater only" echo "::error::offers final releases to builds on null, alpha or beta, so a '$id'" echo "::error::build would never be able to update to a stable version. Use:" echo "::error:: ${tag%%-*}-beta.N" exit 1 fi fi # Zulu rather than Temurin, for one specific reason: the payload script builds the bundled # runtime with jlink, and jlink needs the JDK's `jmods` directory. The Temurin 25 build this # workflow was getting does not ship one, so the first release failed after the jar had # already been built. Zulu includes it. # # A missing jmods no longer stops the build — prepare-payload.mjs falls back to copying the # whole JDK — but that fallback exists for a bad day, not as the normal path: it produces a # noticeably larger installer. - name: Set up JDK 25 uses: actions/setup-java@v5 with: distribution: zulu java-version: "25" cache: maven # Cheap, and answers the question above directly in the log instead of leaving it to be # inferred from a failure ten minutes later. - name: Check the JDK can trim a runtime shell: bash run: | if [ -d "$JAVA_HOME/jmods" ]; then echo "jmods present ($(ls "$JAVA_HOME/jmods" | wc -l) modules) — jlink will trim the runtime." else echo "::warning::$JAVA_HOME has no jmods; the whole JDK will be bundled and the installer will be larger." fi - uses: pnpm/action-setup@v5 - name: Set up Node 24 uses: actions/setup-node@v5 with: node-version: "24" cache: pnpm # Compiled here because it cannot be compiled anywhere else: it is C, built against the # PostgreSQL server headers, for one OS and one major version. It has to run BEFORE the jar # is packaged — it stages into src/main/resources, so `mvn package` carries it into the jar # and the jar into the installer. That is what makes the extension part of what a user # installs rather than something they fetch. # # Required, not continue-on-error. It was the latter on the reasoning that an installer # without semantic ranking beats no installer — but that silently ships an .exe missing a # feature it is supposed to contain, and nobody would notice until a user's log said the # build carried none. CI now compiles this on every push, so a break is caught long before a # release; by the time a tag is cut, a failure here is a real problem and should stop. - name: Build pgvector uses: ./.github/actions/build-pgvector with: pg-major: ${{ env.PG_MAJOR }} pg-full: ${{ env.PG_FULL }} pgvector-version: ${{ env.PGVECTOR_VERSION }} - name: Install dependencies run: pnpm install --frozen-lockfile # Order matters: the frontend is copied into the jar as static resources, so it has to exist # before Maven packages. This is what puts the UI, /api and /ws on one origin. - name: Build frontend run: pnpm --filter frontend build # The PostgreSQL binaries inside the jar are platform-specific and selected by an # OS-activated Maven profile, so this jar is only valid for the runner that built it. That # is why the whole build runs per platform rather than building one jar and packaging it # twice. # Tests are CI's job, not this one's. Running them here again buys nothing — the commit being # tagged has already been through them — and it makes cutting a release depend on the test # suite working in an environment it was never meant to run in. That is not hypothetical: the # store tests need a real PostgreSQL, and PostgreSQL refuses to start under the # administrative account GitHub's Windows runner uses, so the release failed on a platform # difference that had nothing to do with packaging. - name: Build backend jar working-directory: apps/backend run: mvn -B -q clean package -DskipTests # electron-builder stamps the artifacts and the update feed with package.json's version, # which sat at a fixed 1.0.0 while releases were tagged v0.1.0-rcN — so every release # claimed to BE 1.0.0, and the auto-updater, comparing 1.0.0 against 1.0.0, never offered # anyone anything. The tag is the truth about a release's version; stamp it here, at build # time only (nothing is committed). A prerelease tag (v0.1.0-rc6) also stamps a prerelease # version, which is exactly what makes electron-updater follow prereleases on installs of # one: its allowPrerelease defaults to true precisely when the installed version carries a # prerelease component. - name: Stamp the version from the tag if: startsWith(github.ref, 'refs/tags/v') working-directory: apps/desktop shell: bash run: npm pkg set version="${GITHUB_REF_NAME#v}" - name: Build installer if: runner.os != 'macOS' working-directory: apps/desktop # electron-builder picks the current platform's targets from electron-builder.yml. run: pnpm package # Separate from the step above so the signing credentials exist only where they are used: # CSC_LINK in a Windows build's environment would send electron-builder's Windows signer # after a certificate that is not for it. # # Degrades one credential at a time, out loud: no certificate builds unsigned, a certificate # without the notary key signs but skips notarization. Either way the artifacts exist and a # manual run can inspect them — but Gatekeeper only accepts the fully credentialed result, # so a tagged release needs all five secrets (see the docs in packaging/macos.md). - name: Build installer (macOS, signed and notarized) if: runner.os == 'macOS' working-directory: apps/desktop shell: bash env: CSC_LINK: ${{ secrets.MAC_CERT_P12 }} CSC_KEY_PASSWORD: ${{ secrets.MAC_CERT_PASSWORD }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }} # electron-builder invoked directly rather than through `pnpm package` with a trailing # flag: pnpm treats --config.* as its own configuration override and would eat it. run: | set -euo pipefail pnpm run build pnpm run payload if [ -z "${CSC_LINK}" ]; then echo "::warning::MAC_CERT_P12 is not set — building unsigned and without notarization. Gatekeeper will refuse this build on any other machine; fine for checking packaging, never for shipping." export CSC_IDENTITY_AUTO_DISCOVERY=false pnpm exec electron-builder --publish never --config.mac.notarize=false exit 0 fi # Apple's notary unpacks every jar in the app and rejects the submission over any # unsigned Mach-O it finds there — the backend jar (pgvector), jna, onnxruntime and # tokenizers all carry one. electron-builder's signer never opens a jar, so those are # signed here first, with the same certificate imported into a throwaway keychain # (codesign only reads identities out of keychains, and the runner's has nothing). KEYCHAIN="$RUNNER_TEMP/jar-sign.keychain-db" KC_PW=$(openssl rand -hex 16) # The search list as the runner had it, to be put back before electron-builder runs. KEYCHAINS_BEFORE=$(security list-keychains -d user | tr -d '"' | xargs) printf '%s' "$CSC_LINK" | base64 --decode > "$RUNNER_TEMP/jar-sign-cert.p12" security create-keychain -p "$KC_PW" "$KEYCHAIN" security set-keychain-settings -lut 21600 "$KEYCHAIN" security unlock-keychain -p "$KC_PW" "$KEYCHAIN" security import "$RUNNER_TEMP/jar-sign-cert.p12" -k "$KEYCHAIN" -f pkcs12 -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign rm "$RUNNER_TEMP/jar-sign-cert.p12" # Without this, codesign hangs on a UI permission prompt no runner will ever answer. security set-key-partition-list -S apple-tool:,apple: -s -k "$KC_PW" "$KEYCHAIN" > /dev/null security list-keychains -d user -s "$KEYCHAIN" login.keychain-db IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN" | awk -F'"' '/Developer ID Application/ {print $2; exit}') if [ -z "$IDENTITY" ]; then echo "::error::The imported p12 holds no Developer ID Application identity." exit 1 fi node scripts/sign-jar-natives.mjs "$IDENTITY" # Put the search list back, and do it BEFORE electron-builder: it makes a throwaway # keychain of its own and runs `security set-key-partition-list -s`, which walks EVERY # keychain in the list and unlocks each one with ITS password. Ours has a different # password, so the whole build died on "SecKeychainUnlock: The user name or passphrase # you entered is not correct" — twice, once per keychain it could not open — after the # jar natives had already been signed. The signing above needs the list (codesign reads # identities out of it); nothing after it does. # shellcheck disable=SC2086 security list-keychains -d user -s $KEYCHAINS_BEFORE if [ -z "${APPLE_API_KEY_CONTENT}" ]; then echo "::warning::APPLE_API_KEY_CONTENT is not set — signed but NOT notarized, so Gatekeeper will still refuse it on other machines." pnpm exec electron-builder --publish never --config.mac.notarize=false else # electron-builder hands these straight to notarytool: APPLE_API_KEY is a path, so the # key content is written out here rather than passed around. export APPLE_API_KEY="$RUNNER_TEMP/apple_api_key.p8" printf '%s' "$APPLE_API_KEY_CONTENT" > "$APPLE_API_KEY" pnpm exec electron-builder --publish never fi # The keychain above outlives its step, so a dedicated step drops it even when the build # fails; the leftover would only confuse the next debugging session on a rerun. - name: Remove the jar-signing keychain if: always() && runner.os == 'macOS' shell: bash run: security delete-keychain "$RUNNER_TEMP/jar-sign.keychain-db" 2>/dev/null || true - name: Upload installers as artifacts uses: actions/upload-artifact@v5 with: # runner.arch as well as os: the two mac builds would otherwise claim the same name, # and upload-artifact refuses duplicates. name: concentus-${{ runner.os }}-${{ runner.arch }} path: ${{ matrix.artifact }} if-no-files-found: error publish: name: Publish release # Only for a tag. A manual run stops after the artifacts above. if: startsWith(github.ref, 'refs/tags/v') needs: installers runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v5 with: path: artifacts # Each mac runner wrote a latest-mac.yml naming only the arch it built, and a GitHub release # cannot hold two assets with one name. electron-updater reads a single feed and picks the # entry matching the install's arch — so the two feeds become one here. Skipping this would # not fail the release; it would upload whichever feed won, and every install of the OTHER # arch would sit on updates forever. The failure mode this repo's release history is made of. - name: Merge the macOS update feeds shell: bash run: | set -euo pipefail mapfile -t feeds < <(find artifacts -name latest-mac.yml | sort) if [ "${#feeds[@]}" -le 1 ]; then echo "${#feeds[@]} macOS feed(s) — nothing to merge." exit 0 fi python3 -c 'import yaml' 2>/dev/null || pip3 install --quiet pyyaml python3 - "${feeds[@]}" <<'EOF' import sys, yaml paths = sys.argv[1:] docs = [yaml.safe_load(open(p)) for p in paths] base = docs[0] seen = {f["url"] for f in base["files"]} for doc in docs[1:]: for f in doc["files"]: if f["url"] not in seen: base["files"].append(f) seen.add(f["url"]) with open("latest-mac-merged.yml", "w") as out: yaml.safe_dump(base, out, sort_keys=False) EOF rm -- "${feeds[@]}" mkdir -p artifacts/mac-feed mv latest-mac-merged.yml artifacts/mac-feed/latest-mac.yml echo "Merged feed:" cat artifacts/mac-feed/latest-mac.yml - name: Create the release uses: softprops/action-gh-release@v3 with: # Flattened from the per-platform artifact folders. files: artifacts/**/* draft: false generate_release_notes: true fail_on_unmatched_files: true # A tag with a suffix (v1.0.0-rc1, v0.1.0-beta) is published as a pre-release, so trying # the pipeline out does not put a build people will take as final at the top of the # releases page. A plain v1.0.0 is a normal release. prerelease: ${{ contains(github.ref_name, '-') }} # ---------------------------------------------------------------------------- channels # The package-manager channels, fed from the release above. Stable tags only: a prerelease in # winget, npm or brew would be offered to people who never chose the beta train. Each channel is # guarded by its own secret, so a missing token skips that channel with a log line instead of # failing the release that already shipped. channels: name: Update install channels if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') needs: publish runs-on: ubuntu-latest permissions: contents: write # For npm trusted publishing: the workflow proves its identity to npm with an OIDC token # instead of a stored secret. As of July 2026, long-lived publish tokens that bypass 2FA # are on a deprecation clock (no direct publishing from January 2027), so a token here # would be building on sand. id-token: write steps: - uses: actions/checkout@v5 with: ref: main # setup-node v5 reads packageManager from the root package.json and turns pnpm caching on # by itself — and then fails outright if the pnpm binary is absent. This job never runs # pnpm, but the cheapest way to keep setup-node happy is the same pair the installers use. # Found the hard way: this exact step killed v0.1.5's first channels run. - uses: pnpm/action-setup@v5 # Trusted publishing needs npm >= 11.5.1 (Node 24 carries it); the runner's default is # whatever it is, which is exactly the kind of dependency a publish should not have. - uses: actions/setup-node@v5 with: node-version: "24" - name: Compute the installer hash id: hash run: | version="${GITHUB_REF_NAME#v}" url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}/Concentus-Setup-${version}.exe" curl -sL "$url" -o installer.exe echo "sha256=$(sha256sum installer.exe | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" echo "version=$version" >> "$GITHUB_OUTPUT" rm installer.exe # The scoop bucket lives in this repo, so `scoop update` reads whatever is committed here. # checkver/autoupdate in the manifest are for tooling; this is what actually moves users. - name: Update the scoop manifest run: | node -e ' const fs = require("fs"); const j = JSON.parse(fs.readFileSync("bucket/concentus.json", "utf8")); const v = process.env.VERSION, h = process.env.SHA256; j.version = v; j.architecture["64bit"].url = `https://github.com/${process.env.GITHUB_REPOSITORY}/releases/download/v${v}/Concentus-Setup-${v}.exe#/dl.7z`; j.architecture["64bit"].hash = h; fs.writeFileSync("bucket/concentus.json", JSON.stringify(j, null, 4) + "\n"); ' env: VERSION: ${{ steps.hash.outputs.version }} SHA256: ${{ steps.hash.outputs.sha256 }} - name: Commit the scoop bump run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add bucket/concentus.json git diff --cached --quiet || git commit -m "chore(scoop): ${GITHUB_REF_NAME}" git push origin main # winget-pkgs takes a PR per version. wingetcreate reuses the manifests already merged for # the previous version, swaps url+version, and submits — so the very FIRST version must be # submitted by hand (see packaging/winget/README.md); this step carries every one after it. - name: Submit to winget if: env.WINGET_TOKEN != '' env: WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} run: | version="${{ steps.hash.outputs.version }}" url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${version}/Concentus-Setup-${version}.exe" # The binary from Komac's releases, not its ghcr image: the image answered "denied" on # v0.1.5's run — gone or private, and either way not something to depend on. Pinned, so # a komac release cannot change this step's behavior mid-release. KOMAC_VERSION=2.16.0 curl -sfL "https://github.com/russellbanks/Komac/releases/download/v${KOMAC_VERSION}/komac-${KOMAC_VERSION}-x86_64-unknown-linux-gnu.tar.gz" | tar -xz komac_bin=$(find . -maxdepth 2 -name komac -type f | head -1) [ -n "$komac_bin" ] && chmod +x "$komac_bin" \ && "$komac_bin" update Gergilcan.Concentus --version "$version" --urls "$url" \ --submit --token "$WINGET_TOKEN" \ || echo "::warning::winget submission failed — submit by hand with packaging/winget/README.md" # The tap is the repo this workflow owns (Gergilcan/homebrew-concentus), so publishing to # brew is a commit there, not a review queue. Stamped from the template in # packaging/homebrew — the cask's shape lives in this repo, only version and hashes move. # Guarded like the channels around it: no token, or a release without mac artifacts, logs # and moves on rather than failing a release that already shipped. - name: Update the Homebrew tap if: env.HOMEBREW_TAP_TOKEN != '' env: HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} run: | set -euo pipefail version="${{ steps.hash.outputs.version }}" base="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${version}" if ! curl -sfL "$base/Concentus-${version}-arm64.dmg" -o arm.dmg \ || ! curl -sfL "$base/Concentus-${version}-x64.dmg" -o x64.dmg; then echo "::warning::v${version} has no macOS dmg pair — the brew tap was not updated." exit 0 fi arm_sha=$(sha256sum arm.dmg | cut -d' ' -f1) x64_sha=$(sha256sum x64.dmg | cut -d' ' -f1) rm arm.dmg x64.dmg if ! git clone --depth 1 "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/Gergilcan/homebrew-concentus.git" tap; then echo "::warning::could not clone the tap — does Gergilcan/homebrew-concentus exist and does HOMEBREW_TAP_TOKEN reach it?" exit 0 fi mkdir -p tap/Casks sed -e "s/^ version .*/ version \"${version}\"/" \ -e "s/REPLACE_WITH_ARM64_DMG_SHA256/${arm_sha}/" \ -e "s/REPLACE_WITH_X64_DMG_SHA256/${x64_sha}/" \ packaging/homebrew/Casks/concentus.rb > tap/Casks/concentus.rb cd tap git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add Casks/concentus.rb git diff --cached --quiet || git commit -m "concentus ${version}" git push || echo "::warning::pushing the tap failed — update Casks/concentus.rb by hand from packaging/homebrew." # No token: npm's trusted publishing accepts this workflow's OIDC identity, configured # once on the package's settings at npmjs.com (user Gergilcan, repo concentus, workflow # release.yml). The FIRST publish cannot use this — trusted publishers attach to a package # that already exists — so it is done interactively, with 2FA, from packaging/npm. - name: Publish to npm (trusted publishing) run: | cd packaging/npm npm version "${{ steps.hash.outputs.version }}" --no-git-tag-version --allow-same-version npm publish --access public || echo "::warning::npm publish failed — is the trusted publisher configured on npmjs.com, and has the first version been published by hand?" # ---------------------------------------------------------------------------- images # The hub and the runner images on GitHub's registry, from the same commit the installers came # from — packaging/docker/Dockerfile, one build stage and two final targets. Stable tags only, # like the channels: `latest` has to mean the release somebody would get from the downloads # page. Built on a Linux runner because the jar's PostgreSQL binaries are chosen by the OS that # builds it (see the note on the backend jar above); the image is the one artifact where that # rule costs nothing, since the container IS Linux. The Dockerfile builds the jar itself rather # than taking the one from the release, for the same reason. # # continue-on-error, deliberately: by the time this job runs the release exists and the # installers are on it. A registry that is down or a package permission that is wrong must not # turn a shipped release red — the red mark is what people look at before installing, and this # job's failure says nothing about the installers. It still shows as failed in the job list, # and the images can be pushed by hand from packaging/docker with the same two commands. docker: name: Publish Docker images if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') needs: publish runs-on: ubuntu-latest continue-on-error: true permissions: contents: read # GITHUB_TOKEN is enough for ghcr.io: the package is owned by this repository, and the # image's org.opencontainers.image.source label ties the two together on first push. packages: write steps: - uses: actions/checkout@v5 # Buildx, for the cache mounts the Dockerfile uses and for the GitHub Actions layer cache # below: the second image reuses the first one's build stage instead of building it again. - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Version from the tag id: v run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" # The context is the repository root, not packaging/docker: the jar is built from the whole # tree. The root .dockerignore keeps the checkout's noise out of it. - name: Build and push the runner image uses: docker/build-push-action@v6 with: context: . file: packaging/docker/Dockerfile target: runner push: true build-args: | CONCENTUS_VERSION=${{ steps.v.outputs.version }} tags: | ghcr.io/gergilcan/concentus-runner:${{ steps.v.outputs.version }} ghcr.io/gergilcan/concentus-runner:latest cache-from: type=gha cache-to: type=gha,mode=max - name: Build and push the hub image uses: docker/build-push-action@v6 with: context: . file: packaging/docker/Dockerfile target: hub push: true build-args: | CONCENTUS_VERSION=${{ steps.v.outputs.version }} tags: | ghcr.io/gergilcan/concentus-hub:${{ steps.v.outputs.version }} ghcr.io/gergilcan/concentus-hub:latest cache-from: type=gha cache-to: type=gha,mode=max