diff --git a/.github/workflows/docker-publish-on-tag.yml b/.github/workflows/docker-publish-on-tag.yml new file mode 100644 index 00000000..ee12675a --- /dev/null +++ b/.github/workflows/docker-publish-on-tag.yml @@ -0,0 +1,149 @@ +name: Release Docker image on tag (GHCR + Docker Hub) + +on: + push: + tags: ["v*"] # e.g., v1.4.300 + +permissions: + contents: read + packages: write # needed for GHCR with GITHUB_TOKEN + +jobs: + build-and-push: + # Optional safety: only run from your fork + if: ${{ github.repository_owner == 'ksylvan' }} + runs-on: ubuntu-latest + + outputs: + is_latest: ${{ steps.latest.outputs.is_latest }} + owner_lc: ${{ steps.vars.outputs.owner_lc }} + repo_lc: ${{ steps.vars.outputs.repo_lc }} + dockerhub_user_lc: ${{ steps.dh.outputs.user_lc }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # full history for tag comparisons + + - name: Fetch all tags + run: git fetch --tags --force + + # More reliable cross-builds + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Compute lowercase owner/repo for registry image names + - name: Compute image names + id: vars + run: | + OWNER="${GITHUB_REPOSITORY_OWNER}" + REPO="${GITHUB_REPOSITORY#*/}" + echo "owner_lc=${OWNER,,}" >> "$GITHUB_OUTPUT" + echo "repo_lc=${REPO,,}" >> "$GITHUB_OUTPUT" + + # Lowercase Docker Hub username (belt & suspenders) + - name: Lowercase Docker Hub username + id: dh + run: echo "user_lc=${DOCKERHUB_USERNAME,,}" >> "$GITHUB_OUTPUT" + env: + DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME }} + + # Determine if the current tag is the highest vX.Y.Z (no pre-releases) + - name: Is this the latest semver tag? + id: latest + shell: bash + run: | + CTAG="${GITHUB_REF_NAME}" + LATEST="$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n1)" + echo "current_tag=$CTAG" >> "$GITHUB_OUTPUT" + echo "latest_tag=$LATEST" >> "$GITHUB_OUTPUT" + if [[ "$CTAG" == "$LATEST" ]]; then + echo "is_latest=true" >> "$GITHUB_OUTPUT" + else + echo "is_latest=false" >> "$GITHUB_OUTPUT" + fi + + # Login to GHCR (uses built-in GITHUB_TOKEN) + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Login to Docker Hub + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ steps.dh.outputs.user_lc }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + # Generate versioned tags/labels for BOTH registries (no :latest here) + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/${{ steps.vars.outputs.owner_lc }}/${{ steps.vars.outputs.repo_lc }} + docker.io/${{ steps.dh.outputs.user_lc }}/${{ steps.vars.outputs.repo_lc }} + tags: | + type=ref,event=tag # v1.4.300 + type=semver,pattern={{version}} # 1.4.300 (optional) + type=semver,pattern={{major}}.{{minor}} # 1.4 (optional) + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + + - name: Build and push (multi-arch) + uses: docker/build-push-action@v6 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # Separate job to (re)point :latest — serialized to avoid races + move-latest: + needs: build-and-push + if: ${{ needs.build-and-push.outputs.is_latest == 'true' }} + runs-on: ubuntu-latest + + # Only one "latest" move at a time; newer runs cancel older in-progress ones + concurrency: + group: latest-${{ github.repository }} + cancel-in-progress: true + + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Tag :latest on GHCR + run: | + SRC="ghcr.io/${{ needs.build-and-push.outputs.owner_lc }}/${{ needs.build-and-push.outputs.repo_lc }}:${{ github.ref_name }}" + DST="ghcr.io/${{ needs.build-and-push.outputs.owner_lc }}/${{ needs.build-and-push.outputs.repo_lc }}:latest" + docker buildx imagetools create -t "$DST" "$SRC" + + - name: Tag :latest on Docker Hub + run: | + SRC="docker.io/${{ needs.build-and-push.outputs.dockerhub_user_lc }}/${{ needs.build-and-push.outputs.repo_lc }}:${{ github.ref_name }}" + DST="docker.io/${{ needs.build-and-push.outputs.dockerhub_user_lc }}/${{ needs.build-and-push.outputs.repo_lc }}:latest" + docker buildx imagetools create -t "$DST" "$SRC" diff --git a/.github/workflows/patterns.yaml b/.github/workflows/patterns.yaml index 14ecd1af..b547edc2 100644 --- a/.github/workflows/patterns.yaml +++ b/.github/workflows/patterns.yaml @@ -16,17 +16,23 @@ jobs: fetch-depth: 0 - name: Verify Changes in Patterns Folder + id: check-changes run: | git fetch origin if git diff --quiet HEAD~1 -- data/patterns; then echo "No changes detected in patterns folder." - exit 1 + echo "changes=false" >> $GITHUB_OUTPUT + else + echo "Changes detected in patterns folder." + echo "changes=true" >> $GITHUB_OUTPUT fi - name: Zip the Patterns Folder + if: steps.check-changes.outputs.changes == 'true' run: zip -r patterns.zip data/patterns/ - name: Upload Patterns Artifact + if: steps.check-changes.outputs.changes == 'true' uses: actions/upload-artifact@v4 with: name: patterns diff --git a/.vscode/settings.json b/.vscode/settings.json index 06c2a3ed..c4e7d832 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,6 +13,7 @@ "Behrens", "blindspots", "Bombal", + "Buildx", "Callirhoe", "Callirrhoe", "Cerebras", @@ -31,6 +32,7 @@ "Despina", "direnv", "DMARC", + "DOCKERHUB", "dryrun", "dsrp", "editability", @@ -73,6 +75,7 @@ "Hormozi's", "horts", "HTMLURL", + "imagetools", "jaredmontoya", "jessevdk", "Jina", @@ -108,6 +111,7 @@ "ollamaapi", "openaiapi", "opencode", + "opencontainers", "openrouter", "Orus", "osascript", diff --git a/cmd/generate_changelog/incoming/1732.txt b/cmd/generate_changelog/incoming/1732.txt new file mode 100644 index 00000000..0efa2a4b --- /dev/null +++ b/cmd/generate_changelog/incoming/1732.txt @@ -0,0 +1,7 @@ +### PR [#1732](https://github.com/danielmiessler/Fabric/pull/1732) by [ksylvan](https://github.com/ksylvan): CI Infra: Changelog Generation Tool + Docker Image Pubishing + +- Add GitHub Actions workflow to publish Docker images on tags +- Build multi-arch images with Buildx and QEMU across amd64, arm64 +- Tag images using semver; push to GHCR and Docker Hub +- Gate patterns workflow steps on detected changes instead of failing +- Auto-detect GitHub owner and repo from git remote URL diff --git a/cmd/generate_changelog/internal/release.go b/cmd/generate_changelog/internal/release.go index a8e00d39..431bbfc7 100644 --- a/cmd/generate_changelog/internal/release.go +++ b/cmd/generate_changelog/internal/release.go @@ -3,6 +3,9 @@ package internal import ( "context" "fmt" + "os/exec" + "regexp" + "strings" "github.com/danielmiessler/fabric/cmd/generate_changelog/internal/cache" "github.com/danielmiessler/fabric/cmd/generate_changelog/internal/config" @@ -17,17 +20,50 @@ type ReleaseManager struct { repo string } +// getGitHubInfo extracts owner and repo from git remote origin URL +func getGitHubInfo() (owner, repo string, err error) { + cmd := exec.Command("git", "remote", "get-url", "origin") + output, err := cmd.Output() + if err != nil { + return "", "", fmt.Errorf("failed to get git remote URL: %w", err) + } + + url := strings.TrimSpace(string(output)) + + // Handle both SSH and HTTPS URLs + // SSH: git@github.com:owner/repo.git + // HTTPS: https://github.com/owner/repo.git + var re *regexp.Regexp + if strings.HasPrefix(url, "git@") { + re = regexp.MustCompile(`git@github\.com:([^/]+)/([^/.]+)(?:\.git)?`) + } else { + re = regexp.MustCompile(`https://github\.com/([^/]+)/([^/.]+)(?:\.git)?`) + } + + matches := re.FindStringSubmatch(url) + if len(matches) < 3 { + return "", "", fmt.Errorf("invalid GitHub URL format: %s", url) + } + + return matches[1], matches[2], nil +} + func NewReleaseManager(cfg *config.Config) (*ReleaseManager, error) { cache, err := cache.New(cfg.CacheFile) if err != nil { return nil, fmt.Errorf("failed to create cache: %w", err) } + owner, repo, err := getGitHubInfo() + if err != nil { + return nil, fmt.Errorf("failed to get GitHub repository info: %w", err) + } + return &ReleaseManager{ cache: cache, githubToken: cfg.GitHubToken, - owner: "danielmiessler", - repo: "fabric", + owner: owner, + repo: repo, }, nil }