From 32df9fff2bf9416a1d1008e0a7bb5658d0ed0a9b Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Wed, 6 May 2026 09:29:24 -0600 Subject: [PATCH 1/8] .gitignore docs/superpowers specs and plans --- .gitignore | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 37746ba..418439b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ results .dev-* bakery-bake.json .bakery-bake.json -.idea ### Python template # Byte-compiled / optimized / DLL files @@ -167,4 +166,7 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ + +# Claude +docs/superpowers From 5d51b07f31cec51f5849c1fc6a4bbdfe6c4e3aa3 Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Wed, 6 May 2026 10:53:57 -0600 Subject: [PATCH 2/8] ci: scaffold release workflow --- .github/workflows/release.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d644080 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: Release +on: + workflow_dispatch: + inputs: + version: + description: "Workbench version (e.g. 2026.05.0+527.pro2)" + required: true + type: string + +# Security policy: No ${{ }} expressions in `run:` blocks. +# All expression values are assigned to `env:` and referenced as +# shell variables. This prevents script injection from runtime values +# and keeps the rule enforceable by zizmor without per-expression exceptions. + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + steps: + - name: GitHub App Token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + id: app-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + token: ${{ steps.app-token.outputs.token }} + + - name: Install bakery + uses: posit-dev/images-shared/setup-bakery@main From df4c0339624c7ccc9bd5ef7d5fedbfc1c5e5737f Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Wed, 6 May 2026 11:09:22 -0600 Subject: [PATCH 3/8] ci: quote setup-bakery action ref to match local style --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d644080..1486e3e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,4 +32,4 @@ jobs: token: ${{ steps.app-token.outputs.token }} - name: Install bakery - uses: posit-dev/images-shared/setup-bakery@main + uses: "posit-dev/images-shared/setup-bakery@main" From 163488098e8b3261095060e4949b9252664f9332 Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Wed, 6 May 2026 11:18:54 -0600 Subject: [PATCH 4/8] ci(release): parse version into display/edition/build/tag --- .github/workflows/release.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1486e3e..6b8a49b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,3 +33,31 @@ jobs: - name: Install bakery uses: "posit-dev/images-shared/setup-bakery@main" + + - name: Parse version + id: parse + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + VERSION="$INPUT_VERSION" + DISPLAY_VERSION="${VERSION%%[+-]*}" + EDITION="${DISPLAY_VERSION%.*}" + + # Build identifier is the substring after `+` (or `-`). + # When the input has no separator, BUILD is empty. + REST="${VERSION#"$DISPLAY_VERSION"}" + BUILD="${REST#?}" + + if [ -n "$BUILD" ]; then + TAG_VERSION="${DISPLAY_VERSION}-${BUILD}" + else + TAG_VERSION="$DISPLAY_VERSION" + fi + + { + echo "version=$VERSION" + echo "display_version=$DISPLAY_VERSION" + echo "edition=$EDITION" + echo "build=$BUILD" + echo "tag_version=$TAG_VERSION" + } >> "$GITHUB_OUTPUT" From 66dc693362984814a121523dc381c4de3239cc33 Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Wed, 6 May 2026 11:56:54 -0600 Subject: [PATCH 5/8] ci(release): capture current version and emit change flag --- .github/workflows/release.yml | 43 +++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b8a49b..aa2200a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,3 +61,46 @@ jobs: echo "build=$BUILD" echo "tag_version=$TAG_VERSION" } >> "$GITHUB_OUTPUT" + + - name: Capture old version + id: capture + env: + VERSION: ${{ steps.parse.outputs.version }} + run: | + # Both managed images stay in lockstep, so reading from one is + # authoritative. Capturing here, before any mutation, avoids stale + # reads later in the loop. + OLD_VERSION=$(bakery get version workbench-for-google-cloud-workstations 2>/dev/null || true) + + if [ -z "$OLD_VERSION" ]; then + echo "No existing version found. Aborting." + exit 1 + fi + + OLD_DISPLAY="${OLD_VERSION%%[+-]*}" + OLD_EDITION="${OLD_DISPLAY%.*}" + OLD_REST="${OLD_VERSION#"$OLD_DISPLAY"}" + OLD_BUILD="${OLD_REST#?}" + + if [ -n "$OLD_BUILD" ]; then + OLD_TAG="${OLD_DISPLAY}-${OLD_BUILD}" + else + OLD_TAG="$OLD_DISPLAY" + fi + + if [ "$OLD_VERSION" = "$VERSION" ]; then + CHANGED=false + echo "Current version ($OLD_VERSION) matches requested version. Nothing to do." + else + CHANGED=true + echo "Will replace $OLD_VERSION -> $VERSION" + fi + + { + echo "changed=$CHANGED" + echo "old_version=$OLD_VERSION" + echo "old_display=$OLD_DISPLAY" + echo "old_edition=$OLD_EDITION" + echo "old_build=$OLD_BUILD" + echo "old_tag=$OLD_TAG" + } >> "$GITHUB_OUTPUT" From 1362c0844962815da1495251dcf0c0355a4e73cc Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Wed, 6 May 2026 12:07:43 -0600 Subject: [PATCH 6/8] ci(release): replace versions, preserving dep pins on patches --- .github/workflows/release.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa2200a..a6db33a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,3 +104,29 @@ jobs: echo "old_build=$OLD_BUILD" echo "old_tag=$OLD_TAG" } >> "$GITHUB_OUTPUT" + + - name: Replace versions + if: steps.capture.outputs.changed == 'true' + env: + VERSION: ${{ steps.parse.outputs.version }} + EDITION: ${{ steps.parse.outputs.edition }} + OLD_VERSION: ${{ steps.capture.outputs.old_version }} + OLD_EDITION: ${{ steps.capture.outputs.old_edition }} + run: | + IMAGES="workbench-for-google-cloud-workstations workbench-for-microsoft-azure-ml" + + for IMAGE in $IMAGES; do + if [ "$OLD_EDITION" = "$EDITION" ]; then + # Same-edition patch: in-place rename preserves dependency + # pins (R/Python/Quarto) so a patch release does not silently + # re-resolve dependencies. + echo "Patching $IMAGE: $OLD_VERSION -> $VERSION" + bakery update version "$IMAGE" "$VERSION" --target-version "$OLD_VERSION" + else + # Edition bump: create the new version (dependencies re-resolve + # per dependencyConstraints), then remove the old one. + echo "Replacing $IMAGE: $OLD_VERSION -> $VERSION (subpath=$EDITION)" + bakery create version "$IMAGE" "$VERSION" --subpath "$EDITION" --mark-latest + bakery remove version "$IMAGE" "$OLD_VERSION" + fi + done From 3cb14fe74564bd127567c50971788c36b773f384 Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Wed, 6 May 2026 12:14:44 -0600 Subject: [PATCH 7/8] ci(release): keep README version references in sync --- .github/workflows/release.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a6db33a..9906f2e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -130,3 +130,31 @@ jobs: bakery remove version "$IMAGE" "$OLD_VERSION" fi done + + - name: Update READMEs + if: steps.capture.outputs.changed == 'true' + env: + DISPLAY_VERSION: ${{ steps.parse.outputs.display_version }} + EDITION: ${{ steps.parse.outputs.edition }} + TAG_VERSION: ${{ steps.parse.outputs.tag_version }} + OLD_DISPLAY: ${{ steps.capture.outputs.old_display }} + OLD_EDITION: ${{ steps.capture.outputs.old_edition }} + OLD_TAG: ${{ steps.capture.outputs.old_tag }} + run: | + # Pass 1: Replace full registry-tag form (most specific). + # e.g. 2026.01.2-418.pro1 -> 2026.05.0-527.pro2 + # Must run before pass 2 so the build identifier is rewritten as a unit. + find . -name 'README.md' -not -path './.git/*' \ + -exec sed -i "s/${OLD_TAG}/${TAG_VERSION}/g" {} + + + # Pass 2: Replace any remaining display-version references. + # e.g. 2026.01.2 -> 2026.05.0 + find . -name 'README.md' -not -path './.git/*' \ + -exec sed -i "s/${OLD_DISPLAY}/${DISPLAY_VERSION}/g" {} + + + # Pass 3: Replace edition references, but only when the edition + # actually changed (avoids unintended rewrites within the same edition). + if [ "$OLD_EDITION" != "$EDITION" ]; then + find . -name 'README.md' -not -path './.git/*' \ + -exec sed -i "s/${OLD_EDITION}/${EDITION}/g" {} + + fi From b54ceb5cc44228df57ba7fe970c215cf8ef7e178 Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Wed, 6 May 2026 12:16:25 -0600 Subject: [PATCH 8/8] ci(release): commit changes to release branch and open PR --- .github/workflows/release.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9906f2e..2b2b805 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -158,3 +158,38 @@ jobs: find . -name 'README.md' -not -path './.git/*' \ -exec sed -i "s/${OLD_EDITION}/${EDITION}/g" {} + fi + + - name: Create pull request + if: steps.capture.outputs.changed == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + VERSION: ${{ steps.parse.outputs.version }} + DISPLAY_VERSION: ${{ steps.parse.outputs.display_version }} + EDITION: ${{ steps.parse.outputs.edition }} + OLD_VERSION: ${{ steps.capture.outputs.old_version }} + OLD_DISPLAY: ${{ steps.capture.outputs.old_display }} + OLD_EDITION: ${{ steps.capture.outputs.old_edition }} + run: | + BRANCH="release/${DISPLAY_VERSION}" + git fetch origin "$BRANCH" 2>/dev/null || true + git checkout -B "$BRANCH" + git add -A + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git commit -m "Release ${DISPLAY_VERSION}" + git push -u origin "$BRANCH" --force-with-lease + + BODY="Updates Workbench to version \`${VERSION}\`." + BODY+=$'\n\n'"**Images:** workbench-for-google-cloud-workstations, workbench-for-microsoft-azure-ml" + BODY+=$'\n'"**Replaces:** \`${OLD_VERSION}\` → \`${VERSION}\`" + if [ "$OLD_EDITION" != "$EDITION" ]; then + BODY+=$'\n'"**Edition bump:** \`${OLD_EDITION}\` → \`${EDITION}\` (dependencies re-resolved)" + else + BODY+=$'\n'"**Same-edition patch:** dependency pins preserved" + fi + + gh pr create \ + --title "Release ${DISPLAY_VERSION}" \ + --body "$BODY" \ + --base main \ + --head "$BRANCH" || echo "PR already exists"