From 16927720cb361afd9c0b54d1871d55f4199631fc Mon Sep 17 00:00:00 2001 From: Peter Baumgartner Date: Tue, 17 Mar 2026 13:48:54 -0600 Subject: [PATCH 1/8] Warn on EOL stacks (heroku-18, heroku-20) and add builder tests Closes #25 Co-Authored-By: Claude Sonnet 4.6 --- builder/build/appjson.go | 6 ++++++ builder/build/appjson_test.go | 40 +++++++++++++++++++++++++++++------ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/builder/build/appjson.go b/builder/build/appjson.go index 7574002..49a0c23 100644 --- a/builder/build/appjson.go +++ b/builder/build/appjson.go @@ -101,6 +101,9 @@ func patchBuildpack(buildpack string, stack string) string { return buildpack } +// EOLStacks contains stacks that have reached end-of-life +var EOLStacks = []string{"heroku-18", "heroku-20"} + func (a *AppJSON) Unmarshal() error { content, err := a.reader() if err != nil { @@ -115,6 +118,9 @@ func (a *AppJSON) Unmarshal() error { log.Ctx(a.ctx).Error().Err(err).Msg("failed to parse app.json") return err } + if contains(EOLStacks, a.Stack) { + log.Ctx(a.ctx).Warn().Str("stack", a.Stack).Msg("stack is end-of-life and no longer supported; upgrade to heroku-24") + } return nil } diff --git a/builder/build/appjson_test.go b/builder/build/appjson_test.go index 416c6f6..dd6eb17 100644 --- a/builder/build/appjson_test.go +++ b/builder/build/appjson_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "reflect" + "strings" "testing" "github.com/rs/zerolog" @@ -72,13 +73,40 @@ func TestAppJsonStack(t *testing.T) { } func TestAppJsonBuilders(t *testing.T) { - a := AppJSON{ - Stack: "heroku-22", - ctx: testContext, + tests := []struct { + stack string + expected []string + }{ + {"heroku-18", []string{"heroku/buildpacks:18", "heroku/heroku:18-cnb"}}, + {"heroku-20", []string{"heroku/buildpacks:20", "heroku/heroku:20-cnb"}}, + {"heroku-22", []string{"heroku/builder:22", "heroku/heroku:22-cnb"}}, + {"custom/builder:latest", []string{"custom/builder:latest"}}, + } + for _, tt := range tests { + a := AppJSON{Stack: tt.stack, ctx: testContext} + if !stringSliceEqual(a.GetBuilders(), tt.expected) { + t.Errorf("stack %s: expected %s, got %s", tt.stack, tt.expected, a.GetBuilders()) + } } - expected := []string{"heroku/builder:22", "heroku/heroku:22-cnb"} - if !stringSliceEqual(a.GetBuilders(), expected) { - t.Errorf("expected %s, got %s", expected, a.GetBuilders()) +} + +func TestAppJsonEOLStackWarning(t *testing.T) { + for _, stack := range EOLStacks { + var buf strings.Builder + logger := zerolog.New(&buf) + ctx := logger.WithContext(context.Background()) + a := AppJSON{ + reader: func() ([]byte, error) { + return []byte(`{"stack": "` + stack + `"}`), nil + }, + ctx: ctx, + } + if err := a.Unmarshal(); err != nil { + t.Fatalf("stack %s: unexpected error: %s", stack, err) + } + if !strings.Contains(buf.String(), "end-of-life") { + t.Errorf("stack %s: expected EOL warning in log output, got: %s", stack, buf.String()) + } } } From 57af8c1b70af27cf7b189f898e9de3f1695c2d20 Mon Sep 17 00:00:00 2001 From: Peter Baumgartner Date: Tue, 17 Mar 2026 13:52:47 -0600 Subject: [PATCH 2/8] Remove heroku-20 integration test (EOL) Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/build.yml | 61 ------------------------------------- 1 file changed, 61 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5fdab66..e313fb0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -218,66 +218,6 @@ jobs: cat apppack.toml test "$(python -c 'import tomllib; print(tomllib.load(open("apppack.toml", "rb"))["services"]["web"]["command"])')" = "bash -c 'gunicorn --access-logfile - --bind 0.0.0.0:\$PORT --forwarded-allow-ips '\"'\"'*'\"'\"' app:app'" - integration-heroku20: - runs-on: ubuntu-latest - needs: [test, build-image] - permissions: - id-token: write - contents: read - packages: read - steps: - - name: Login to GitHub Container Registry - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Pull image - run: docker pull ${{ needs.build-image.outputs.image }} - - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: arn:aws:iam::891426818781:role/github-actions-integration-tests - aws-region: us-east-1 - - name: Checkout sample repo - run: git clone --branch buildpacks-20 https://github.com/apppackio/apppack-demo-python.git - - name: Run integration tests - working-directory: ./apppack-demo-python - run: | - cat < .envfile - APPNAME=gh-integration - CODEBUILD_BUILD_ID=demo-python:${{ github.run_id }} - CODEBUILD_SOURCE_VERSION=${{ github.sha }} - DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }} - DOCKERHUB_ACCESS_TOKEN=${{ secrets.DOCKERHUB_ACCESS_TOKEN }} - DOCKER_REPO=891426818781.dkr.ecr.us-east-1.amazonaws.com/github-integration-test - ARTIFACT_BUCKET=integration-test-buildartifacts - ALLOW_EOL_SHIMMED_BUILDER=1 - AWS_REGION - AWS_ACCESS_KEY_ID - AWS_SECRET_ACCESS_KEY - AWS_SESSION_TOKEN - EOF - - docker run \ - --rm \ - --privileged \ - --env-file .envfile \ - --volume /var/run/docker.sock:/var/run/docker.sock \ - --volume "$(pwd):/app" \ - --workdir /app \ - ${{ needs.build-image.outputs.image }} \ - /bin/sh -c "set -x; git config --global --add safe.directory /app && apppack-builder prebuild; apppack-builder build; apppack-builder postbuild" - - uses: actions/setup-python@v4 - with: - python-version: "3.11" - - name: Verify apppack.toml - working-directory: ./apppack-demo-python - run: | - set -ex - cat apppack.toml - test "$(python -c 'import tomllib; print(tomllib.load(open("apppack.toml", "rb"))["services"]["web"]["command"])')" = 'gunicorn --access-logfile - --bind 0.0.0.0:$PORT --forwarded-allow-ips '"'"'*'"' app:app" - integration-docker: runs-on: ubuntu-latest needs: [test, build-image] @@ -346,7 +286,6 @@ jobs: - integration - integration-docker - integration-appjson - - integration-heroku20 - integration-heroku24 permissions: id-token: write From 7581ff1d0aea66d72a851a92b880d1eaaf9bf2c4 Mon Sep 17 00:00:00 2001 From: Peter Baumgartner Date: Tue, 17 Mar 2026 13:53:16 -0600 Subject: [PATCH 3/8] Update CHANGELOG for heroku-20 EOL changes Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ccbacc..0c30b64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +* Warn when an EOL stack (`heroku-18` or `heroku-20`) is specified in `app.json` +* Removed `heroku-20` integration test from CI (stack is end-of-life) + ## [2.5.0] - 2025-10-21 ### Added From 7568fbd29f26894647ee3313c9118f041281f19a Mon Sep 17 00:00:00 2001 From: Peter Baumgartner Date: Tue, 17 Mar 2026 13:54:27 -0600 Subject: [PATCH 4/8] Revise CHANGELOG to be more end-user facing Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c30b64..7a12214 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -* Warn when an EOL stack (`heroku-18` or `heroku-20`) is specified in `app.json` -* Removed `heroku-20` integration test from CI (stack is end-of-life) +* Builds using the `heroku-20` stack will now log a warning that the stack is end-of-life and should be upgraded to `heroku-24` ## [2.5.0] - 2025-10-21 From 83145093e4dba07bf3c9984b39911d6f521bcda2 Mon Sep 17 00:00:00 2001 From: Peter Baumgartner Date: Tue, 17 Mar 2026 13:56:33 -0600 Subject: [PATCH 5/8] Remove legacy pack binary and heroku-20 pack selection logic heroku-20 is EOL; the legacy pack v0.31.0 is no longer needed. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + Dockerfile | 11 +++-------- builder/build/build.go | 10 ++-------- 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a12214..14a63f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed * Builds using the `heroku-20` stack will now log a warning that the stack is end-of-life and should be upgraded to `heroku-24` +* Removed support for `heroku-20` builds (previously required a legacy version of `pack`) ## [2.5.0] - 2025-10-21 diff --git a/Dockerfile b/Dockerfile index 79823bd..dcf0f85 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,9 @@ FROM golang:1.25-alpine AS builder RUN apk add --no-cache curl -# Install current `pack` and v0.31.0, the last pack version that supports heroku/buildpacks:20 builder ENV PACK_VER=0.38.0 RUN set -ex && \ - mkdir -p /tmp/legacy-pack /tmp/current-pack && \ - cd /tmp/legacy-pack && \ - curl -sLO "https://github.com/buildpacks/pack/releases/download/v0.31.0/pack-v0.31.0-linux.tgz" && \ - tar xvzf "pack-v0.31.0-linux.tgz" && \ - cd /tmp/current-pack && \ + mkdir -p /tmp/pack && \ + cd /tmp/pack && \ curl -sLO "https://github.com/buildpacks/pack/releases/download/v$PACK_VER/pack-v$PACK_VER-linux.tgz" && \ tar xvzf "pack-v$PACK_VER-linux.tgz" @@ -16,7 +12,6 @@ COPY ./builder . RUN go build -o /go/bin/apppack-builder main.go FROM docker:27-dind -COPY --from=builder /tmp/legacy-pack/pack /usr/local/bin/pack-legacy -COPY --from=builder /tmp/current-pack/pack /usr/local/bin/pack +COPY --from=builder /tmp/pack/pack /usr/local/bin/pack RUN apk add --no-cache git COPY --from=builder /go/bin/apppack-builder /usr/local/bin/apppack-builder diff --git a/builder/build/build.go b/builder/build/build.go index 3a7605d..a03268a 100644 --- a/builder/build/build.go +++ b/builder/build/build.go @@ -144,13 +144,7 @@ func (b *Build) buildWithDocker(config *containers.BuildConfig) error { func (b *Build) buildWithPack(config *containers.BuildConfig) error { b.Log().Debug().Msg("pack config registry-mirrors") builder := b.BuildpackBuilders()[0] - packBinary := "pack" - if builder == "heroku/buildpacks:20" { - // use legacy pack for heroku/buildpacks:20 - packBinary = "pack-legacy" - b.Log().Debug().Msg(fmt.Sprintf("using legacy pack version for %s", builder)) - } - cmd := exec.Command(packBinary, "config", "registry-mirrors", "add", "index.docker.io", "--mirror", DockerHubMirror) + cmd := exec.Command("pack", "config", "registry-mirrors", "add", "index.docker.io", "--mirror", DockerHubMirror) if err := cmd.Run(); err != nil { return err } @@ -170,7 +164,7 @@ func (b *Build) buildWithPack(config *containers.BuildConfig) error { } packArgs = append(packArgs, config.Image) b.Log().Debug().Str("builder", builder).Str("buildpacks", buildpacks).Msg("building image") - cmd = exec.Command(packBinary, packArgs...) + cmd = exec.Command("pack", packArgs...) out := io.MultiWriter(os.Stdout, config.LogFile) cmd.Stdout = out cmd.Stderr = out From 7a375a74bd5f49dd81028469fe1036a7731192a6 Mon Sep 17 00:00:00 2001 From: Peter Baumgartner Date: Tue, 17 Mar 2026 13:57:37 -0600 Subject: [PATCH 6/8] Simplify CHANGELOG entry for heroku-20 EOL Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14a63f3..f3343d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -* Builds using the `heroku-20` stack will now log a warning that the stack is end-of-life and should be upgraded to `heroku-24` -* Removed support for `heroku-20` builds (previously required a legacy version of `pack`) +* Removed support for EOL `heroku-20` builds ## [2.5.0] - 2025-10-21 From 9673df56182e41025104c5acd66f1753685d8b50 Mon Sep 17 00:00:00 2001 From: Peter Baumgartner Date: Tue, 17 Mar 2026 14:11:57 -0600 Subject: [PATCH 7/8] Fail immediately when an EOL stack is specified Co-Authored-By: Claude Sonnet 4.6 --- builder/build/appjson.go | 3 ++- builder/build/appjson_test.go | 21 +++++++++------------ 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/builder/build/appjson.go b/builder/build/appjson.go index 49a0c23..e467c12 100644 --- a/builder/build/appjson.go +++ b/builder/build/appjson.go @@ -3,6 +3,7 @@ package build import ( "context" "encoding/json" + "fmt" "os" "github.com/rs/zerolog/log" @@ -119,7 +120,7 @@ func (a *AppJSON) Unmarshal() error { return err } if contains(EOLStacks, a.Stack) { - log.Ctx(a.ctx).Warn().Str("stack", a.Stack).Msg("stack is end-of-life and no longer supported; upgrade to heroku-24") + return fmt.Errorf("stack %q is end-of-life and no longer supported; upgrade to heroku-24", a.Stack) } return nil } diff --git a/builder/build/appjson_test.go b/builder/build/appjson_test.go index dd6eb17..4da2d20 100644 --- a/builder/build/appjson_test.go +++ b/builder/build/appjson_test.go @@ -59,7 +59,7 @@ func TestAppJsonMissing(t *testing.T) { func TestAppJsonStack(t *testing.T) { a := AppJSON{ reader: func() ([]byte, error) { - return []byte(`{"stack": "heroku-18"}`), nil + return []byte(`{"stack": "heroku-22"}`), nil }, ctx: testContext, } @@ -67,7 +67,7 @@ func TestAppJsonStack(t *testing.T) { if err != nil { t.Errorf("expected no error, got %s", err) } - if a.Stack != "heroku-18" { + if a.Stack != "heroku-22" { t.Errorf("expected heroku-22, got %s", a.Stack) } } @@ -90,22 +90,19 @@ func TestAppJsonBuilders(t *testing.T) { } } -func TestAppJsonEOLStackWarning(t *testing.T) { +func TestAppJsonEOLStackError(t *testing.T) { for _, stack := range EOLStacks { - var buf strings.Builder - logger := zerolog.New(&buf) - ctx := logger.WithContext(context.Background()) a := AppJSON{ reader: func() ([]byte, error) { return []byte(`{"stack": "` + stack + `"}`), nil }, - ctx: ctx, + ctx: testContext, } - if err := a.Unmarshal(); err != nil { - t.Fatalf("stack %s: unexpected error: %s", stack, err) - } - if !strings.Contains(buf.String(), "end-of-life") { - t.Errorf("stack %s: expected EOL warning in log output, got: %s", stack, buf.String()) + err := a.Unmarshal() + if err == nil { + t.Errorf("stack %s: expected error for EOL stack, got nil", stack) + } else if !strings.Contains(err.Error(), "end-of-life") { + t.Errorf("stack %s: expected end-of-life error, got: %s", stack, err) } } } From ae89352e38903863b2598a185231a8f86c6780ff Mon Sep 17 00:00:00 2001 From: Peter Baumgartner Date: Tue, 17 Mar 2026 14:20:18 -0600 Subject: [PATCH 8/8] Remove dead code for EOL stacks (heroku-18, heroku-20) Co-Authored-By: Claude Sonnet 4.6 --- builder/build/appjson.go | 27 --------------------------- builder/build/appjson_test.go | 18 ------------------ 2 files changed, 45 deletions(-) diff --git a/builder/build/appjson.go b/builder/build/appjson.go index e467c12..ab2209a 100644 --- a/builder/build/appjson.go +++ b/builder/build/appjson.go @@ -32,27 +32,6 @@ const DefaultStack = "heroku-22" // buildpacks included in builder var IncludedBuildpacks = map[string][]string{ - "heroku-20": { - // $ pack builder inspect heroku/buildpacks:20 -o json | jq '.remote_info.buildpacks[].id' - "heroku/builder-eol-warning", - "heroku/go", - "heroku/gradle", - "heroku/java", - "heroku/jvm", - "heroku/maven", - "heroku/nodejs", - "heroku/nodejs-corepack", - "heroku/nodejs-engine", - "heroku/nodejs-npm-engine", - "heroku/nodejs-npm-install", - "heroku/nodejs-pnpm-install", - "heroku/nodejs-yarn", - "heroku/php", - "heroku/procfile", - "heroku/python", - "heroku/ruby", - "heroku/scala", - }, "heroku-22": { // $ pack builder inspect heroku/builder:22 -o json | jq '.remote_info.buildpacks[].id' "heroku/deb-packages", @@ -143,12 +122,6 @@ func ParseAppJson(ctx context.Context) (*AppJSON, error) { // the first item in the list is the builder, followed by the stack image // the stack image is only used for prefetching, so non-heroku stacks should still work func (a *AppJSON) GetBuilders() []string { - if a.Stack == "heroku-18" { - return []string{"heroku/buildpacks:18", "heroku/heroku:18-cnb"} - } - if a.Stack == "heroku-20" { - return []string{"heroku/buildpacks:20", "heroku/heroku:20-cnb"} - } if a.Stack == "heroku-22" { return []string{"heroku/builder:22", "heroku/heroku:22-cnb"} } diff --git a/builder/build/appjson_test.go b/builder/build/appjson_test.go index 4da2d20..a0ecda7 100644 --- a/builder/build/appjson_test.go +++ b/builder/build/appjson_test.go @@ -8,7 +8,6 @@ import ( "testing" "github.com/rs/zerolog" - "github.com/rs/zerolog/log" ) func stringSliceEqual(a, b []string) bool { @@ -25,21 +24,6 @@ func stringSliceEqual(a, b []string) bool { var testContext = zerolog.New(os.Stdout).With().Timestamp().Logger().WithContext(context.Background()) -func TestAppJsonBuildpackPatch(t *testing.T) { - a := AppJSON{ - Buildpacks: []Buildpack{ - {URL: "heroku/nodejs"}, - {URL: "heroku/python"}, - }, - Stack: "heroku-20", - ctx: log.With().Logger().WithContext(context.Background()), - } - expected := []string{"urn:cnb:builder:heroku/nodejs", "urn:cnb:builder:heroku/python"} - if !stringSliceEqual(a.GetBuildpacks(), expected) { - t.Errorf("expected %s, got %s", expected, a.GetBuildpacks()) - } -} - func TestAppJsonMissing(t *testing.T) { a := AppJSON{ reader: func() ([]byte, error) { @@ -77,8 +61,6 @@ func TestAppJsonBuilders(t *testing.T) { stack string expected []string }{ - {"heroku-18", []string{"heroku/buildpacks:18", "heroku/heroku:18-cnb"}}, - {"heroku-20", []string{"heroku/buildpacks:20", "heroku/heroku:20-cnb"}}, {"heroku-22", []string{"heroku/builder:22", "heroku/heroku:22-cnb"}}, {"custom/builder:latest", []string{"custom/builder:latest"}}, }