diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..63a2afa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.github +bin +dist +Formula +**/node_modules +**/target +**/dist +**/coverage +**/.dart_tool +**/.fvm +**/.mason +**/.DS_Store diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 859642a..fa286ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,3 +110,33 @@ jobs: shell: pwsh run: ./scripts/check.ps1 working-directory: ${{ runner.temp }}/foundry-rust + + docker: + name: Docker distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: docker build --build-arg VERSION=ci -t foundry:test . + - run: ./scripts/test-docker.sh foundry:test + - run: test "$(docker run --rm foundry:test version)" = 'foundry ci' + + homebrew: + name: Homebrew distribution + runs-on: macos-latest + env: + HOMEBREW_NO_AUTO_UPDATE: '1' + HOMEBREW_NO_INSTALL_CLEANUP: '1' + steps: + - uses: actions/checkout@v4 + - name: Check and install the release formula + env: + GH_TOKEN: ${{ github.token }} + run: | + version=$(sed -n 's/^ version "\(.*\)"/\1/p' Formula/foundry.rb) + gh release download "v$version" --repo "$GITHUB_REPOSITORY" --pattern SHA256SUMS --dir "$RUNNER_TEMP/homebrew-release" + ./scripts/homebrew-formula.sh "$version" "$RUNNER_TEMP/homebrew-release/SHA256SUMS" > "$RUNNER_TEMP/foundry.rb" + diff -u Formula/foundry.rb "$RUNNER_TEMP/foundry.rb" + ruby -c Formula/foundry.rb + brew tap --custom-remote mohuganda/foundry "$GITHUB_WORKSPACE" + brew install mohuganda/foundry/foundry + brew test mohuganda/foundry/foundry diff --git a/.github/workflows/distribute.yml b/.github/workflows/distribute.yml new file mode 100644 index 0000000..cc47282 --- /dev/null +++ b/.github/workflows/distribute.yml @@ -0,0 +1,106 @@ +name: Distribute release + +on: + workflow_run: + workflows: [Release] + types: [completed] + workflow_dispatch: + inputs: + version: + description: Existing stable release tag to distribute (for example v1.3.0) + required: true + default: v1.3.0 + +permissions: + contents: read + +concurrency: + group: distribute-release + cancel-in-progress: false + +jobs: + distribute: + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_repository.full_name == github.repository && + startsWith(github.event.workflow_run.head_branch, 'v')) + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v4 + - name: Resolve published release + id: release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.inputs.version || github.event.workflow_run.head_branch }} + shell: bash + run: | + [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo 'Only stable vX.Y.Z tags are supported.'; exit 1; } + gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft,isPrerelease --jq 'if .isDraft or .isPrerelease then error("Expected a published stable release") else . end' + image="ghcr.io/${GITHUB_REPOSITORY,,}" + echo "tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT" + echo "version=${RELEASE_TAG#v}" >> "$GITHUB_OUTPUT" + echo "image=$image" >> "$GITHUB_OUTPUT" + if [[ "$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName)" == "$RELEASE_TAG" ]]; then + echo "latest-tag=$image:latest" >> "$GITHUB_OUTPUT" + fi + - uses: actions/checkout@v4 + with: + ref: ${{ steps.release.outputs.tag }} + path: source + persist-credentials: false + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - name: Build and test the native container + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + docker build --build-arg VERSION="$VERSION" -f Dockerfile -t foundry:test source + ./scripts/test-docker.sh foundry:test + test "$(docker run --rm foundry:test version)" = "foundry $VERSION" + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Publish multi-platform image + uses: docker/build-push-action@v6 + with: + context: source + file: Dockerfile + platforms: linux/amd64,linux/arm64 + build-args: VERSION=${{ steps.release.outputs.version }} + push: true + tags: | + ${{ steps.release.outputs.image }}:${{ steps.release.outputs.version }} + ${{ steps.release.outputs.latest-tag }} + - name: Update the Homebrew tap for the latest stable release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + shell: bash + run: | + latest=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName) + if [[ "$latest" != "$RELEASE_TAG" ]]; then + echo 'Historical image published; retaining the latest Homebrew version.' + exit 0 + fi + mkdir -p "$RUNNER_TEMP/homebrew-release" + gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --pattern SHA256SUMS --dir "$RUNNER_TEMP/homebrew-release" + ./scripts/homebrew-formula.sh "$RELEASE_TAG" "$RUNNER_TEMP/homebrew-release/SHA256SUMS" > "$RUNNER_TEMP/foundry.rb" + ruby -c "$RUNNER_TEMP/foundry.rb" + branch=$(gh api "repos/$GITHUB_REPOSITORY" --jq .default_branch) + endpoint="repos/$GITHUB_REPOSITORY/contents/Formula/foundry.rb" + current=$(gh api "$endpoint?ref=$branch" --jq .sha) + if [[ "$(git hash-object "$RUNNER_TEMP/foundry.rb")" == "$current" ]]; then + echo 'Homebrew formula is already current.' + exit 0 + fi + gh api "$endpoint" --method PUT \ + -f message="chore: update Homebrew to $RELEASE_TAG [skip ci]" \ + -f branch="$branch" -f sha="$current" \ + -f content="$(base64 -w 0 "$RUNNER_TEMP/foundry.rb")" --silent diff --git a/CHANGELOG.md b/CHANGELOG.md index ca30e90..4898424 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Add a Homebrew tap backed by this repository's Formula directory and verified macOS/Linux release archives. +- Add a non-root Docker image with all five embedded foundations, offline generation tests, and multi-platform GHCR publishing after stable releases. +- Automate Homebrew version/checksum updates after successful release publication and document distribution usage and maintenance. + ## 1.3.0 - 2026-10-24 ### Compatibility notes diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5442434 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +# syntax=docker/dockerfile:1 +FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS build +ARG TARGETOS=linux +ARG TARGETARCH +ARG VERSION=dev +WORKDIR /src +COPY . . +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build \ + -trimpath -ldflags "-s -w -X github.com/jabahum/foundry/internal/cli.Version=${VERSION}" \ + -o /out/foundry ./cmd/foundry \ + && mkdir -p /out/workspace + +FROM scratch +ARG VERSION=dev +LABEL org.opencontainers.image.title="Foundry" \ + org.opencontainers.image.description="Offline project generation from five embedded foundations" \ + org.opencontainers.image.source="https://github.com/mohuganda/foundry" \ + org.opencontainers.image.version=$VERSION +COPY --from=build /out/foundry /usr/local/bin/foundry +COPY --from=build --chown=65532:65532 /out/workspace /workspace +USER 65532:65532 +WORKDIR /workspace +ENTRYPOINT ["/usr/local/bin/foundry"] +CMD ["help"] diff --git a/Formula/foundry.rb b/Formula/foundry.rb new file mode 100644 index 0000000..490d426 --- /dev/null +++ b/Formula/foundry.rb @@ -0,0 +1,38 @@ +class Foundry < Formula + desc "Create and extend projects from reusable foundations" + homepage "https://github.com/mohuganda/foundry" + version "1.3.0" + + on_macos do + on_arm do + url "https://github.com/mohuganda/foundry/releases/download/v1.3.0/foundry_1.3.0_darwin_arm64.tar.gz" + sha256 "66b4ee1bda6fbc1f4aa78529f2ab0d1d24ce49c3748180f753d3cd2b25ab048b" + end + on_intel do + url "https://github.com/mohuganda/foundry/releases/download/v1.3.0/foundry_1.3.0_darwin_amd64.tar.gz" + sha256 "7084a8760ab09a7c0d56afbd30862e09a4deba15e706d5a21adde277192d8886" + end + end + + on_linux do + on_arm do + url "https://github.com/mohuganda/foundry/releases/download/v1.3.0/foundry_1.3.0_linux_arm64.tar.gz" + sha256 "1a7ea9791e800a9a75334cad036a8cbed3e25fd56748ce34d38f10f4a95e113e" + end + on_intel do + url "https://github.com/mohuganda/foundry/releases/download/v1.3.0/foundry_1.3.0_linux_amd64.tar.gz" + sha256 "396b9211215abc1467daeba4ebd20159009845de72b7b61fa68c29c1d6d6cf59" + end + end + + def install + bin.install "foundry" + doc.install "README.md", "CHANGELOG.md", "docs/commands.md", "docs/workspaces.md" + end + + test do + assert_match "foundry #{version}", shell_output("#{bin}/foundry version") + system bin/"foundry", "new", "brew-smoke", "--template", "rust", "--no-hooks" + assert_path_exists testpath/"brew-smoke/Cargo.toml" + end +end diff --git a/README.md b/README.md index 46065bc..fd16d15 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,30 @@ archives are built for: Download the archive for your platform from GitHub Releases, verify it against `SHA256SUMS`, extract it, and place `foundry` (or `foundry.exe`) on your `PATH`. +### Homebrew (macOS and Linux) + +```sh +brew tap mohuganda/foundry https://github.com/mohuganda/foundry +brew install mohuganda/foundry/foundry +foundry version +``` + +The tap uses this repository's `Formula/` directory and installs verified release binaries for Apple Silicon/Intel macOS and arm64/amd64 Linux. Upgrade with `brew update && brew upgrade mohuganda/foundry/foundry`. + +### Docker (Linux containers) + +```sh +docker run --rm ghcr.io/mohuganda/foundry:1.3.0 version +# From the parent directory where the generated project should be written: +docker run --rm --user "$(id -u):$(id -g)" \ + --mount "type=bind,source=$(pwd),target=/workspace" \ + ghcr.io/mohuganda/foundry:1.3.0 new my-api --template go --no-hooks +``` + +Images support linux/amd64 and linux/arm64. Pin a version for reproducibility; `latest` follows the latest stable release. The image contains the CLI and embedded templates. Install language SDKs and run generated-project development commands on the host or in a separate development container. Use `--no-hooks` because generation hooks need SDKs that this image does not include. See [distribution details](docs/distribution.md) for Windows mounts, interactive use, and publishing. + +### Go source installation + Install directly from source with Go 1.23 or newer: ```bash diff --git a/docs/commands.md b/docs/commands.md index 6c375d9..708c87a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2,6 +2,8 @@ This guide covers the Foundry executable, development of Foundry itself, and commands inside generated projects. Run `foundry help` for CLI syntax, `make help` in either Make-based project for targets, and a shell script with `--help` for its inputs. Rust's PowerShell script uses `-Help`. +See [Distribution](distribution.md) for Homebrew installation, Docker usage, and release publication automation. + ## Choose a command category The [workspace guide](workspaces.md) explains which commands apply to each workspace and gives creation-to-validation recipes. This page is the detailed reference. diff --git a/docs/distribution.md b/docs/distribution.md new file mode 100644 index 0000000..6f3d70e --- /dev/null +++ b/docs/distribution.md @@ -0,0 +1,117 @@ +# Distribution + +Foundry ships as a standalone executable with five embedded source foundations. GitHub Releases, Homebrew, and Docker distribute the same CLI. Template downloads and separate template packages are unnecessary. + +## Homebrew + +The existing Foundry repository doubles as a custom tap; the explicit repository URL is required when first adding it: + +```sh +brew tap mohuganda/foundry https://github.com/mohuganda/foundry +brew install mohuganda/foundry/foundry +foundry version +``` + +The formula selects the macOS/Linux archive for arm64 or amd64, verifies its SHA-256, and installs the executable and documentation. No Go SDK is needed. The fully qualified formula name avoids collisions with other tools named Foundry. + +```sh +brew update +brew upgrade mohuganda/foundry/foundry +brew test mohuganda/foundry/foundry +brew uninstall mohuganda/foundry/foundry +``` + +Homebrew's formula test checks the version and generates a Rust project without hooks. Generated files stay independent of the installed formula; upgrading/uninstalling the CLI does not upgrade/delete your applications. + +## Docker + +Images are published to `ghcr.io/mohuganda/foundry` for Linux amd64 and arm64. Docker Desktop runs these Linux images on macOS and Windows. Version tags such as `1.3.0` identify a release; `latest` follows the latest stable GitHub release. Use a pinned version or image digest for repeatable automation. + +```sh +docker run --rm ghcr.io/mohuganda/foundry:1.3.0 help +docker run --rm ghcr.io/mohuganda/foundry:1.3.0 list +``` + +### Generate into the host directory + +On macOS/Linux, run from the parent directory where the new project belongs: + +```sh +docker run --rm --user "$(id -u):$(id -g)" \ + --mount "type=bind,source=$(pwd),target=/workspace" \ + ghcr.io/mohuganda/foundry:1.3.0 \ + new customer-mobile --template flutter --no-hooks +``` + +`/workspace` is the image's working directory. The bind mount persists generated output on the host; `--rm` removes the container afterward. Supplying your UID/GID gives the files your host ownership. The image defaults to non-root UID/GID 65532; on Linux, grant that user access or use the UID/GID override above. The host directory must be writable by the selected user. + +Native Windows PowerShell with Docker Desktop in Linux-container mode: + +```powershell +docker run --rm --mount "type=bind,source=$($PWD.Path),target=/workspace" ` + ghcr.io/mohuganda/foundry:1.3.0 new dashboard --template react --no-hooks +``` + +Docker Desktop handles host file access on Windows. For interactive creation on macOS/Linux, add `-it` and run the executable without arguments, overriding the image's default help command: + +```sh +docker run --rm -it --user "$(id -u):$(id -g)" \ + --mount "type=bind,source=$(pwd),target=/workspace" \ + --entrypoint "" ghcr.io/mohuganda/foundry:1.3.0 /usr/local/bin/foundry +``` + +The image's default command is `help`; the example explicitly runs the executable without arguments to select interactive creation. + +### Extend or inspect a mounted project + +```sh +# Run from the parent containing my-api/: +docker run --rm --user "$(id -u):$(id -g)" \ + --mount "type=bind,source=$(pwd),target=/workspace" \ + ghcr.io/mohuganda/foundry:1.3.0 add service orders --project my-api +``` + +`--project` and `--destination` refer to container paths. A destination outside the mounted directory will not persist after the container is removed. The image supports generation, built-in Go/Flutter scaffolding, and read-only template version checks. It contains no shell, Go/Flutter/Node/Rust SDK, Mason executable, database, or Docker daemon. Use `--no-hooks` when generating; develop and validate the result using the host's native tools. `doctor` inside the image reports container tooling, rather than the host's environment. + +### Build and check locally + +From the Foundry repository with Docker running: + +```sh +docker build --build-arg VERSION=dev -t foundry:test . +./scripts/test-docker.sh foundry:test +``` + +The multi-stage Dockerfile compiles a static CLI and copies it into a minimal scratch image. Build context exclusions omit local dependencies and build outputs. `test-docker.sh` runs version/help and generates all five templates into a mounted temporary directory with networking disabled, checking manifests and executable script permissions. It cleans up the temporary directory on exit. Docker is required; generated-project SDKs are not. + +## Release automation + +1. Merge validated code, create a stable `vX.Y.Z` tag, and push it. +2. `Release` checks Foundry, builds six executable archives plus `SHA256SUMS`, and publishes the GitHub release. +3. `Distribute release` runs after that successful tag-triggered workflow. It verifies a published stable release exists, builds/tests its CLI image, then publishes Linux amd64/arm64 images to GHCR. +4. For the latest stable release, it also updates `Formula/foundry.rb` on the default branch using that release's checksums. Historical backfills publish only their versioned image and preserve the latest formula/image alias. + +The distribution workflow uses the repository's `GITHUB_TOKEN` with `packages: write` and `contents: write`. No cross-repository token or separate tap repository is needed. The default branch must permit its formula-update commit; if branch rules prohibit it, apply the generated formula through a normal reviewed PR instead. Formula-only automation commits include `[skip ci]`; CI already tests the generator and installation of the checked-in formula. + +On first GHCR publication, check the package's settings and ensure visibility is **Public** for anonymous pulls. GitHub initially creates packages as private; the image's source label links it to this repository. Repository/workflow policies may restrict package creation or token permissions. + +To backfill an already published stable release, or retry a failed distribution run: + +```sh +gh workflow run distribute.yml --repo mohuganda/foundry -f version=v1.3.0 +``` + +Manual `Release` workflow runs only validate archives; they do not start automatic distribution. Stable tags are supported; prerelease tags are not distributed by this workflow. + +### Regenerate a formula manually + +Download the release checksum manifest, then run from the Foundry repository: + +```sh +gh release download v1.3.0 --repo mohuganda/foundry \ + --pattern SHA256SUMS --dir /tmp/foundry-homebrew +./scripts/homebrew-formula.sh 1.3.0 /tmp/foundry-homebrew/SHA256SUMS > Formula/foundry.rb +ruby -c Formula/foundry.rb +``` + +The generator accepts a stable version with or without its `v` prefix, requires one valid SHA-256 per supported macOS/Linux archive, and writes the formula to stdout. Review the diff and commit it through the usual workflow. Archive URLs and hashes always refer to an existing release; building local snapshots does not change the tap. diff --git a/internal/generator/distribution_test.go b/internal/generator/distribution_test.go new file mode 100644 index 0000000..d1f36d5 --- /dev/null +++ b/internal/generator/distribution_test.go @@ -0,0 +1,66 @@ +package generator + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestHomebrewFormulaReleaseChecksums(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX release helper") + } + script, err := filepath.Abs("../../scripts/homebrew-formula.sh") + if err != nil { + t.Fatal(err) + } + var entries []string + for i, platform := range []string{"darwin_arm64", "darwin_amd64", "linux_arm64", "linux_amd64"} { + entries = append(entries, strings.Repeat(string(rune('a'+i)), 64)+" foundry_1.3.0_"+platform+".tar.gz\n") + } + manifest := strings.Join(entries, "") + for _, tc := range []struct { + name, version, manifest string + wantFailure bool + }{ + {"stable", "1.3.0", manifest, false}, + {"tag", "v1.3.0", manifest, false}, + {"missing", "1.3.0", strings.Join(entries[:3], ""), true}, + {"duplicate", "1.3.0", manifest + entries[0], true}, + {"invalid digest", "1.3.0", strings.Replace(manifest, "aaaa", "zzzz", 1), true}, + {"short digest", "1.3.0", strings.Replace(manifest, "aaaa", "a", 1), true}, + {"wrong release", "1.3.1", manifest, true}, + {"prerelease", "1.3.0-rc.1", manifest, true}, + {"invalid version", "1.3.0\"", manifest, true}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + checksums := filepath.Join(dir, "SHA256SUMS") + if err := os.WriteFile(checksums, []byte(tc.manifest), 0o600); err != nil { + t.Fatal(err) + } + cmd := exec.Command("sh", script, tc.version, checksums) + cmd.Dir = dir + output, err := cmd.Output() + if tc.wantFailure { + if err == nil || len(output) != 0 { + t.Fatalf("invalid input emitted a formula: %s; error: %v", output, err) + } + return + } + if err != nil { + t.Fatal(err) + } + formula := string(output) + for _, entry := range entries { + fields := strings.Fields(entry) + if !strings.Contains(formula, fields[0]) || !strings.Contains(formula, "/v1.3.0/"+fields[1]) { + t.Fatalf("formula lost the release URL or checksum for %s", fields[1]) + } + } + }) + } +} diff --git a/scripts/homebrew-formula.sh b/scripts/homebrew-formula.sh new file mode 100755 index 0000000..b46aaf4 --- /dev/null +++ b/scripts/homebrew-formula.sh @@ -0,0 +1,67 @@ +#!/bin/sh +set -eu +case "${1:-}" in + -h|--help) + echo 'Usage: scripts/homebrew-formula.sh ' + echo 'Print a Homebrew formula for a stable release using its four macOS/Linux archive checksums.' + exit 0 ;; +esac +[ "$#" -eq 2 ] || { echo 'Expected version and checksum manifest; use --help.' >&2; exit 2; } +version=${1#v} +printf '%s\n' "$version" | awk '/^[0-9]+\.[0-9]+\.[0-9]+$/ { valid=1 } END { exit !valid }' || { + echo 'Expected a stable version such as 1.3.0.' >&2; exit 2; +} +manifest=$2 +checksum() { + digest=$(awk -v file="foundry_${version}_$1_$2.tar.gz" '$2 == file { value=$1; count++ } END { if (count != 1) exit 1; print value }' "$manifest") || { + echo "Missing or duplicate checksum for $1/$2." >&2; exit 1; + } + [ "${#digest}" -eq 64 ] || { echo 'Invalid SHA-256 length.' >&2; exit 1; } + case "$digest" in *[!0-9a-fA-F]*) echo 'Invalid SHA-256 digest.' >&2; exit 1 ;; esac + printf '%s' "$digest" +} +# Validate every required digest before emitting a formula. +darwin_arm64=$(checksum darwin arm64) +darwin_amd64=$(checksum darwin amd64) +linux_arm64=$(checksum linux arm64) +linux_amd64=$(checksum linux amd64) +cat <&2; exit 2; } +image=${1:-foundry:test} +workspace=$(mktemp -d) +trap 'rm -rf "$workspace"' EXIT HUP INT TERM +run() { + docker run --rm --network none --user "$(id -u):$(id -g)" \ + --mount "type=bind,source=$workspace,target=/workspace" "$image" "$@" +} +run version +run help +for template in go flutter vanilla-js react rust; do + run new "$template" --template "$template" --destination "$template" --no-hooks + test -f "$workspace/$template/.foundry.yaml" +done +test -f "$workspace/go/go.mod" +test -f "$workspace/flutter/pubspec.yaml" +test -f "$workspace/vanilla-js/package.json" +test -f "$workspace/react/package.json" +test -f "$workspace/rust/Cargo.toml" +test -x "$workspace/rust/scripts/check.sh" +echo 'Docker offline generation checks passed for all five templates.'