diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 74aac42e7d..454fc1f900 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -8,14 +8,22 @@ on: - "v*" paths-ignore: - ".github/workflows/**" + # Use 'released' instead of 'published' so editing/re-publishing old releases + # does NOT re-trigger this workflow. 'released' fires only on the initial + # release publication (and pre-release → release transition). release: - types: [published] + types: [released] workflow_dispatch: inputs: version: - description: "Version tag to build (e.g. 2.6.0)" + description: "Version tag to build (e.g. 3.8.4)" required: true type: string + promote_latest: + description: "Also tag :latest (only if this is the highest semver)" + required: false + type: boolean + default: false permissions: contents: read @@ -32,50 +40,134 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }} + # Need full tag history for semver comparison when deciding :latest. + fetch-depth: 0 + + - name: Resolve version, latest-promotion, and skip flag + id: version + env: + EVENT_NAME: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} + REF_TYPE: ${{ github.ref_type }} + INPUT_VERSION: ${{ inputs.version }} + PROMOTE_INPUT: ${{ inputs.promote_latest }} + run: | + set -euo pipefail + + # 1) Resolve version string from the trigger (all inputs come via env). + case "$EVENT_NAME" in + workflow_dispatch) + VERSION="${INPUT_VERSION#v}" + ;; + push) + if [ "$REF_TYPE" = "tag" ]; then + VERSION="${REF_NAME#v}" + else + # Push to main → build & tag as `main` only. Never touch :latest. + VERSION="main" + fi + ;; + release) + VERSION="${REF_NAME#v}" + ;; + *) + VERSION="${REF_NAME#v}" + ;; + esac + # Sanity-check: only allow [A-Za-z0-9._-] in VERSION (defense in depth). + if ! printf '%s' "$VERSION" | grep -qE '^[A-Za-z0-9._-]+$'; then + echo "Refusing to use unsafe VERSION value: $VERSION" >&2 + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + # 2) Decide whether to promote :latest. + PROMOTE="false" + if [ "$VERSION" = "main" ]; then + PROMOTE="false" + elif printf '%s' "$VERSION" | grep -qE -- '-(rc|alpha|beta|pre|next)'; then + echo "Pre-release identifier detected — skipping :latest." + PROMOTE="false" + elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then + PROMOTE="${PROMOTE_INPUT:-false}" + else + git fetch --tags --quiet || true + HIGHEST=$(git tag -l 'v[0-9]*' | sed 's/^v//' | grep -vE -- '-(rc|alpha|beta|pre|next)' | sort -V | tail -1 || echo "") + if [ -n "$HIGHEST" ] && [ "$VERSION" = "$HIGHEST" ]; then + PROMOTE="true" + else + echo "Version $VERSION is not the highest semver tag (highest=${HIGHEST:-}). Not promoting :latest." + fi + fi + echo "promote_latest=$PROMOTE" >> "$GITHUB_OUTPUT" + + # 3) Skip if this exact version is already published in Docker Hub. + # `main` is always rebuilt (mutable floating tag). + SKIP="false" + if [ "$VERSION" != "main" ]; then + if docker manifest inspect "diegosouzapw/omniroute:${VERSION}" >/dev/null 2>&1; then + echo "Image diegosouzapw/omniroute:${VERSION} already exists on Docker Hub — skipping rebuild." + SKIP="true" + fi + fi + echo "skip=$SKIP" >> "$GITHUB_OUTPUT" + + echo "Publishing diegosouzapw/omniroute:$VERSION (promote_latest=$PROMOTE, skip=$SKIP)" - name: Set up QEMU (for multi-arch builds) + if: steps.version.outputs.skip != 'true' uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx + if: steps.version.outputs.skip != 'true' uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub + if: steps.version.outputs.skip != 'true' uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry + if: steps.version.outputs.skip != 'true' uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract version from release tag or input - id: version + - name: Compute image tags + id: tags + if: steps.version.outputs.skip != 'true' + env: + VERSION: ${{ steps.version.outputs.version }} + PROMOTE_LATEST: ${{ steps.version.outputs.promote_latest }} run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - VERSION="${{ inputs.version }}" - else - VERSION="${GITHUB_REF_NAME}" - VERSION="${VERSION#v}" + set -euo pipefail + TAGS="${IMAGE_NAME}:${VERSION}" + TAGS="${TAGS}"$'\n'"ghcr.io/diegosouzapw/omniroute:${VERSION}" + if [ "$PROMOTE_LATEST" = "true" ]; then + TAGS="${TAGS}"$'\n'"${IMAGE_NAME}:latest" + TAGS="${TAGS}"$'\n'"ghcr.io/diegosouzapw/omniroute:latest" fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "Publishing Docker image: $IMAGE_NAME:$VERSION" + { + echo "tags<> "$GITHUB_OUTPUT" + echo "Tags to push:" + echo "$TAGS" - name: Build and push multi-arch image + if: steps.version.outputs.skip != 'true' uses: docker/build-push-action@v7 with: context: . target: runner-base platforms: linux/amd64,linux/arm64 push: true - tags: | - ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} - ${{ env.IMAGE_NAME }}:latest - ghcr.io/diegosouzapw/omniroute:${{ steps.version.outputs.version }} - ghcr.io/diegosouzapw/omniroute:latest + tags: ${{ steps.tags.outputs.tags }} cache-from: type=gha cache-to: type=gha,mode=max no-cache: false @@ -83,10 +175,16 @@ jobs: DOCKER_BUILDKIT_INLINE_CACHE: 1 - name: Inspect image + if: steps.version.outputs.skip != 'true' && steps.version.outputs.version != 'main' + env: + VERSION: ${{ steps.version.outputs.version }} run: | - docker buildx imagetools inspect "${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}" + docker buildx imagetools inspect "${IMAGE_NAME}:${VERSION}" - name: Update Docker Hub description + # Only refresh README/description when we actually promote :latest + # (avoids overwriting from main pushes or back-fill builds). + if: steps.version.outputs.skip != 'true' && steps.version.outputs.promote_latest == 'true' uses: peter-evans/dockerhub-description@v5 with: username: ${{ secrets.DOCKERHUB_USERNAME }} diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 059d467d8b..e28289d49f 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -1,8 +1,11 @@ name: Publish to npm on: + # 'released' (not 'published') so editing/re-publishing old releases does NOT + # re-trigger this workflow. Pairs with the semver guard below as defense in + # depth against accidental dist-tag clobbering by old releases. release: - types: [published] + types: [released] workflow_dispatch: inputs: version: @@ -10,13 +13,15 @@ on: required: true type: string tag: - description: "npm dist-tag (latest / next)" + description: "npm dist-tag (auto / latest / next / historic)" required: false - default: "latest" + default: "auto" type: choice options: + - auto - latest - next + - historic workflow_call: inputs: version: @@ -24,9 +29,9 @@ on: required: true type: string tag: - description: "npm dist-tag (latest / next)" + description: "npm dist-tag (auto / latest / next / historic)" required: false - default: "latest" + default: "auto" type: string secrets: NPM_TOKEN: @@ -47,6 +52,10 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + # Need full tag history to compare against highest semver when + # deciding whether this release should claim dist-tag `latest`. + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v6 @@ -57,80 +66,110 @@ jobs: - name: Install dependencies (skip scripts to avoid heavy build) run: npm install --ignore-scripts --no-audit --no-fund - - name: Resolve version and dist-tag + - name: Resolve version, dist-tag and skip flag id: resolve + env: + EVENT_NAME: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_TAG: ${{ inputs.tag }} run: | - VERSION="${{ inputs.version }}" - TAG="${{ inputs.tag }}" + set -euo pipefail - if [ -z "$VERSION" ]; then - if [ "${{ github.event_name }}" = "release" ]; then - VERSION="${GITHUB_REF_NAME}" - fi + # 1) Resolve VERSION from the trigger (all inputs come via env). + VERSION="${INPUT_VERSION:-}" + if [ -z "$VERSION" ] && [ "$EVENT_NAME" = "release" ]; then + VERSION="$REF_NAME" + fi + VERSION="${VERSION#v}" + if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.-]+)?$'; then + echo "Refusing to publish unsafe VERSION value: $VERSION" >&2 + exit 1 fi - # Strip v prefix if present - VERSION="${VERSION#v}" - - # Default dist-tag logic - if [ -z "$TAG" ]; then - if [[ "$VERSION" == *-* ]]; then + # 2) Resolve dist-tag. + # - explicit 'latest'/'next'/'historic' is honored + # - 'auto' (or empty): pre-release identifiers → 'next'; + # stable versions → 'latest' only if VERSION is the highest + # stable semver among `v*` tags (otherwise → 'historic'). + REQUESTED_TAG="${INPUT_TAG:-auto}" + TAG="$REQUESTED_TAG" + if [ "$TAG" = "auto" ] || [ -z "$TAG" ]; then + if printf '%s' "$VERSION" | grep -qE -- '-(rc|alpha|beta|pre|next)'; then TAG="next" else - TAG="latest" + git fetch --tags --quiet || true + HIGHEST=$(git tag -l 'v[0-9]*' | sed 's/^v//' | grep -vE -- '-(rc|alpha|beta|pre|next)' | sort -V | tail -1 || echo "") + if [ -n "$HIGHEST" ] && [ "$VERSION" = "$HIGHEST" ]; then + TAG="latest" + else + echo "Version $VERSION is not the highest semver tag (highest=${HIGHEST:-}). Using dist-tag 'historic' to avoid clobbering @latest." + TAG="historic" + fi fi fi - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "tag=$TAG" >> $GITHUB_OUTPUT - echo "📦 Publishing omniroute@$VERSION with tag=$TAG" + + # 3) Skip-if-already-published. NOTE: do NOT pass `--silent` to + # `npm view` — it suppresses stdout and breaks the grep, which + # caused old releases (3.2.8) to be re-published and steal + # dist-tag `latest`. See incident notes in CHANGELOG. + PUBLISHED="$(npm view "omniroute@${VERSION}" version 2>/dev/null || true)" + SKIP="false" + if [ "$PUBLISHED" = "$VERSION" ]; then + echo "⚠️ omniroute@${VERSION} is already on npm — skipping publish." + SKIP="true" + fi + + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "skip=$SKIP" >> "$GITHUB_OUTPUT" + echo "📦 Resolved omniroute@$VERSION dist-tag=$TAG skip=$SKIP" - name: Sync package.json version + if: steps.resolve.outputs.skip != 'true' + env: + VERSION: ${{ steps.resolve.outputs.version }} run: | - npm version "${{ steps.resolve.outputs.version }}" --no-git-tag-version --allow-same-version + npm version "$VERSION" --no-git-tag-version --allow-same-version - name: Build CLI bundle (standalone app) + if: steps.resolve.outputs.skip != 'true' env: JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation run: npm run build:cli - name: Validate npm package artifact + if: steps.resolve.outputs.skip != 'true' run: npm run check:pack-artifact - name: Publish to npm - run: | - VERSION="${{ steps.resolve.outputs.version }}" - TAG="${{ steps.resolve.outputs.tag }}" - # Check if this version is already published — skip instead of failing with E403 - if npm view "omniroute@${VERSION}" version --silent 2>/dev/null | grep -q "^${VERSION}$"; then - echo "⚠️ Version ${VERSION} is already published on npm — skipping." - exit 0 - fi - if [ "$TAG" = "latest" ]; then - npm publish --access public - else - npm publish --access public --tag "$TAG" - fi - echo "✅ Published omniroute@$VERSION (tag: $TAG)" + if: steps.resolve.outputs.skip != 'true' env: + VERSION: ${{ steps.resolve.outputs.version }} + TAG: ${{ steps.resolve.outputs.tag }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + # Always pass --tag explicitly. Defense in depth: even if VERSION is + # accidentally an older release, `npm publish --tag historic` will + # NOT promote it to `@latest`. + npm publish --access public --tag "$TAG" + echo "✅ Published omniroute@$VERSION (dist-tag=$TAG)" - name: Publish to GitHub Packages - run: | - VERSION="${{ steps.resolve.outputs.version }}" - TAG="${{ steps.resolve.outputs.tag }}" - - echo "Configuring for GitHub Packages..." - echo "//npm.pkg.github.com/:_authToken=${{ secrets.GITHUB_TOKEN }}" > .npmrc - npm pkg set name="@diegosouzapw/omniroute" - - if [ "$TAG" = "latest" ]; then - npm publish --registry=https://npm.pkg.github.com || echo "⚠️ Version ${VERSION} might already be published on GitHub." - else - npm publish --registry=https://npm.pkg.github.com --tag "$TAG" || echo "⚠️ Version ${VERSION} might already be published on GitHub." - fi - echo "✅ Action finished for GitHub Packages" + if: steps.resolve.outputs.skip != 'true' env: + VERSION: ${{ steps.resolve.outputs.version }} + TAG: ${{ steps.resolve.outputs.tag }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + echo "Configuring for GitHub Packages..." + echo "//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}" > .npmrc + npm pkg set name="@diegosouzapw/omniroute" + npm publish --registry=https://npm.pkg.github.com --tag "$TAG" \ + || echo "⚠️ omniroute@${VERSION} might already be published on GitHub Packages." + echo "✅ Action finished for GitHub Packages" publish-opencode-plugin: runs-on: ubuntu-latest @@ -159,14 +198,17 @@ jobs: - name: Publish @omniroute/opencode-plugin to npm working-directory: "@omniroute/opencode-plugin" + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | + set -euo pipefail PKG_VERSION=$(node -p "require('./package.json').version") PKG_NAME=$(node -p "require('./package.json').name") - if npm view "${PKG_NAME}@${PKG_VERSION}" version --silent 2>/dev/null | grep -q "^${PKG_VERSION}$"; then + # Same hardened skip-check as the main job (no --silent flag). + PUBLISHED="$(npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null || true)" + if [ "$PUBLISHED" = "$PKG_VERSION" ]; then echo "⚠️ ${PKG_NAME}@${PKG_VERSION} is already published on npm — skipping." exit 0 fi npm publish --access public --ignore-scripts echo "✅ Published ${PKG_NAME}@${PKG_VERSION}" - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}